Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5a05a419 | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 38c7545413 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
+9
-8
@@ -30,12 +30,13 @@ yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
||||
# migrations add` after the initial 4 silently produced a migration git would
|
||||
# never see, while the (tracked) snapshot's changes committed normally —
|
||||
# so the snapshot kept claiming tables existed that no migration in git
|
||||
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
|
||||
# tables were missing from the actual database for exactly this reason.
|
||||
# Migrations now stay tracked like any other source file — commit them.
|
||||
# Each dev keeps EF Core migrations local; DB schema changes are announced
|
||||
# to the team instead of committed, so migration files aren't shared here.
|
||||
Migrations/
|
||||
|
||||
@@ -33,10 +33,11 @@ public sealed class BundleSalesController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -11,8 +12,13 @@ namespace ERPCore.Controllers;
|
||||
public sealed class ItemsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemService _items;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
|
||||
public ItemsController(IItemService items) => _items = items;
|
||||
public ItemsController(IItemService items, IUomConverter uomConverter)
|
||||
{
|
||||
_items = items;
|
||||
_uomConverter = uomConverter;
|
||||
}
|
||||
|
||||
/// <summary>List items with optional filters and paging.</summary>
|
||||
[HttpGet]
|
||||
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
||||
|
||||
/// <summary>
|
||||
/// The UOMs this item may be transacted in — its base UOM plus every UOM it has a
|
||||
/// conversion from, each with the factor to base. Document line forms use this to offer
|
||||
/// only units that will survive posting, instead of the whole global UOM list (FR-MD-02/03).
|
||||
/// </summary>
|
||||
[HttpGet("{itemId:int}/uoms")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<AllowedUomDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<AllowedUomDto>>> GetAllowedUoms(int itemId, CancellationToken ct)
|
||||
=> Ok(await _uomConverter.GetAllowedUomsAsync(itemId, ct));
|
||||
}
|
||||
|
||||
@@ -11,9 +11,20 @@ public class BundleSaleLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Component quantity, in <see cref="UomId"/> — what the user entered.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||
/// consumes this. <see cref="Qty"/> and <see cref="UnitPrice"/> are always a matching
|
||||
/// pair in <see cref="UomId"/>, so <see cref="LineTotal"/> stays value-correct.
|
||||
/// </summary>
|
||||
public decimal QtyBase { get; set; }
|
||||
|
||||
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||
public decimal ConversionFactor { get; set; } = 1m;
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -34,8 +34,20 @@ public class GrnLine
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
/// <summary>Quantity received, in <see cref="UomId"/> — what the user entered.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
|
||||
/// Confirm reads this snapshot rather than re-converting, so a conversion factor edited
|
||||
/// between create and confirm cannot change what a saved GRN posts, and a later reversal
|
||||
/// reproduces the original layer exactly.
|
||||
/// </summary>
|
||||
public decimal QtyBase { get; set; }
|
||||
|
||||
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||
public decimal ConversionFactor { get; set; } = 1m;
|
||||
|
||||
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
|
||||
@@ -21,8 +21,32 @@ public class PoLine
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
/// <summary>Quantity ordered, in <see cref="UomId"/>.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitPrice { get; set; }//
|
||||
public decimal Tax { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
|
||||
/// This — not <see cref="Qty"/> — is what open-quantity and close checks compare against,
|
||||
/// because a GRN may legitimately receive against this line in a different UOM.
|
||||
/// </summary>
|
||||
public decimal QtyBase { get; set; }
|
||||
|
||||
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||
public decimal ConversionFactor { get; set; } = 1m;
|
||||
|
||||
/// <summary>
|
||||
/// Accrues as GRNs confirm (FR-PROC-07), in <see cref="UomId"/>. <b>Denormalized for
|
||||
/// display only</b> — it is derived by dividing <see cref="QtyReceivedBase"/> by the
|
||||
/// factor, so it can drift. Never branch on it; use <see cref="QtyReceivedBase"/>.
|
||||
/// </summary>
|
||||
public decimal QtyReceived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative received-to-date in the item's base UOM. GRN confirm accrues here and
|
||||
/// the PO close condition compares this against <see cref="QtyBase"/>, so receipts in a
|
||||
/// UOM other than the PO's still add up correctly.
|
||||
/// </summary>
|
||||
public decimal QtyReceivedBase { get; set; }
|
||||
}
|
||||
|
||||
@@ -14,10 +14,24 @@ public class SalesInvoiceLine
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
|
||||
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
|
||||
/// </summary>
|
||||
public decimal QtyBase { get; set; }
|
||||
|
||||
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
|
||||
public decimal FreeQtyBase { get; set; }
|
||||
|
||||
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
|
||||
public decimal ConversionFactor { get; set; } = 1m;
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -14,10 +14,24 @@ public class SalesSlipLine
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
|
||||
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
|
||||
/// </summary>
|
||||
public decimal QtyBase { get; set; }
|
||||
|
||||
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
|
||||
public decimal FreeQtyBase { get; set; }
|
||||
|
||||
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
|
||||
public decimal ConversionFactor { get; set; } = 1m;
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -5,9 +5,18 @@ namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A PO line. <paramref name="Qty"/> and <paramref name="QtyReceived"/> are in
|
||||
/// <paramref name="UomId"/> and are what the user sees; <paramref name="QtyBase"/> and
|
||||
/// <paramref name="QtyReceivedBase"/> are the item's base UOM and are what the server
|
||||
/// actually enforces — GRN over-receipt and the PO close condition both run on the base
|
||||
/// pair, because goods may legitimately be received in a different UOM from the one
|
||||
/// ordered. A client showing remaining/outstanding quantity should read the base pair.
|
||||
/// </summary>
|
||||
public sealed record PoLineDto(
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived,
|
||||
decimal QtyBase = 0m, decimal QtyReceivedBase = 0m, decimal ConversionFactor = 1m);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
|
||||
|
||||
@@ -31,9 +31,10 @@ public sealed record BundleSaleTemplateSummaryDto(
|
||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
||||
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||
public sealed record BundleSalePostingIssueDto(
|
||||
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, string BaseUomName = "");
|
||||
|
||||
public sealed record BundleSalePostingCheckDto(
|
||||
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
||||
|
||||
@@ -24,9 +24,16 @@ public sealed record SalesInvoiceSummaryDto(
|
||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A line that cannot be posted for lack of stock. The three quantities are in the item's
|
||||
/// <b>base</b> UOM (on-hand only exists in base), which may differ from the UOM shown on the
|
||||
/// line — hence <paramref name="BaseUomName"/>: without it a line reading "2 BOX" produces
|
||||
/// an unexplained "requested 24, available 10".
|
||||
/// </summary>
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
|
||||
string BaseUomName = "");
|
||||
|
||||
public sealed record SalesInvoicePostingCheckDto(
|
||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||
|
||||
@@ -23,9 +23,11 @@ public sealed record SalesSlipSummaryDto(
|
||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||
|
||||
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||
public sealed record SalesSlipPostingIssueDto(
|
||||
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
|
||||
string BaseUomName = "");
|
||||
|
||||
public sealed record SalesSlipPostingCheckDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
||||
|
||||
@@ -2,16 +2,23 @@ using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Every quantity a stock endpoint returns is in the item's base UOM — stock, layers and the
|
||||
// ledger are base-only by construction. The BaseUomId/BaseUomName pair on each of these DTOs
|
||||
// exists so a client can *label* those figures; without it every stock screen renders a bare
|
||||
// number the user has to guess the unit of. They are never a conversion instruction.
|
||||
|
||||
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
||||
public sealed record StockOnHandDto(
|
||||
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
||||
public sealed record StockLedgerRowDto(
|
||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||
@@ -19,4 +26,5 @@ public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, d
|
||||
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationDto(
|
||||
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
@@ -9,3 +9,12 @@ public sealed class CreateUomRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A UOM an item may actually be transacted in: its base UOM (<paramref name="Factor"/> 1,
|
||||
/// <paramref name="IsBase"/> true) plus every UOM it has a conversion from. Backs both
|
||||
/// entry-time validation and <c>GET /items/{itemId}/uoms</c>, so the client can offer only
|
||||
/// units that will survive posting instead of the whole global list.
|
||||
/// </summary>
|
||||
/// <param name="Factor">Multiply a quantity in this UOM by <paramref name="Factor"/> to get base UOM.</param>
|
||||
public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
|
||||
builder.HasKey(x => x.BundleSaleLineId);
|
||||
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
builder.HasKey(l => l.GrnLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
|
||||
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -49,6 +49,9 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
||||
builder.HasKey(l => l.PoLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyReceivedBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(l => l.Tax).HasPrecision(9, 4);
|
||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||
|
||||
@@ -70,6 +70,9 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
|
||||
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -70,6 +70,9 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
|
||||
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomCon
|
||||
|
||||
// One conversion per (item, from, to) triple.
|
||||
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
|
||||
|
||||
// The API guards this too, but UomConverter divides unit cost by the factor — a zero
|
||||
// reaching the table from a seeder or direct SQL would be a divide-by-zero at post time.
|
||||
builder.ToTable(t => t.HasCheckConstraint("ck_uom_conversions_factor_positive", "\"Factor\" > 0"));
|
||||
}
|
||||
}
|
||||
|
||||
-6696
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-6696
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SyncCurrentModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations;
|
||||
|
||||
public partial class AddBundleSalesModule : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bundle_sale_templates",
|
||||
columns: table => new
|
||||
{
|
||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table => table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId));
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bundle_sales",
|
||||
columns: table => new
|
||||
{
|
||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
||||
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
||||
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
|
||||
table.ForeignKey("FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sales_customers_CustomerId", x => x.CustomerId, "customers", "CustomerId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sales_users_CashierUserId", x => x.CashierUserId, "users", "UserId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sales_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bundle_sale_template_lines",
|
||||
columns: table => new
|
||||
{
|
||||
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||
Qty = 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),
|
||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
|
||||
table.ForeignKey("FK_bundle_sale_template_lines_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey("FK_bundle_sale_template_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sale_template_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sale_template_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bundle_sale_lines",
|
||||
columns: table => new
|
||||
{
|
||||
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarehouseId = table.Column<int>(type: "integer", 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),
|
||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
ParentLineId = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
|
||||
table.ForeignKey("FK_bundle_sale_lines_bundle_sales_BundleSaleId", x => x.BundleSaleId, "bundle_sales", "BundleSaleId", onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey("FK_bundle_sale_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sale_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey("FK_bundle_sale_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_templates_TemplateCode", table: "bundle_sale_templates", column: "TemplateCode", unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleNo", table: "bundle_sales", column: "BundleNo", unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleSaleTemplateId", table: "bundle_sales", column: "BundleSaleTemplateId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CashierUserId", table: "bundle_sales", column: "CashierUserId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CustomerId", table: "bundle_sales", column: "CustomerId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_Status", table: "bundle_sales", column: "Status");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_WarehouseId", table: "bundle_sales", column: "WarehouseId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_BundleSaleTemplateId", table: "bundle_sale_template_lines", column: "BundleSaleTemplateId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_ItemId", table: "bundle_sale_template_lines", column: "ItemId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_UomId", table: "bundle_sale_template_lines", column: "UomId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_WarehouseId", table: "bundle_sale_template_lines", column: "WarehouseId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_BundleSaleId", table: "bundle_sale_lines", column: "BundleSaleId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_ItemId", table: "bundle_sale_lines", column: "ItemId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_UomId", table: "bundle_sale_lines", column: "UomId");
|
||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_WarehouseId", table: "bundle_sale_lines", column: "WarehouseId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable("bundle_sale_lines");
|
||||
migrationBuilder.DropTable("bundle_sale_template_lines");
|
||||
migrationBuilder.DropTable("bundle_sales");
|
||||
migrationBuilder.DropTable("bundle_sale_templates");
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations;
|
||||
|
||||
public partial class AddBundleSalesConcurrencyStamp : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ConcurrencyStamp",
|
||||
table: "bundle_sale_templates",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ConcurrencyStamp",
|
||||
table: "bundle_sales",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConcurrencyStamp",
|
||||
table: "bundle_sales");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConcurrencyStamp",
|
||||
table: "bundle_sale_templates");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
@@ -37,6 +38,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
@@ -50,6 +52,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
@@ -85,7 +88,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -93,6 +96,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
var total = await q.CountAsync(ct);
|
||||
@@ -204,21 +208,30 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
if (r.Qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
||||
if (r.WarehouseId != warehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
||||
|
||||
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
|
||||
// Keep any per-line warehouse input from drifting away from the header.
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
|
||||
// Keep the entered UOM on the line and snapshot the base quantity beside it, the
|
||||
// same shape as invoices and slips. Qty and UnitPrice stay a matching pair in the
|
||||
// entered UOM so LineTotal — which Recalculate rolls into the header — is the
|
||||
// value the user priced; only QtyBase crosses into the base-UOM stock engine.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = resolved.UnitPrice,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = r.UnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
|
||||
@@ -7,6 +7,7 @@ using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -138,6 +139,12 @@ public sealed class GrnService : IGrnService
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
// Resolve the base quantity at entry. This rejects a UOM the item cannot be
|
||||
// received in while the GRN is still a draft, and snapshots the factor so confirm
|
||||
// (and any later reversal) reproduces exactly this quantity.
|
||||
var conversionFactor = await _uomConverter.ResolveFactorAsync(item, input.UomId, ct);
|
||||
var qtyBaseEntered = UomConverter.ApplyFactor(input.Qty, conversionFactor);
|
||||
|
||||
// Cost: for a PO line, the PO price is used unless an override is entered (then it
|
||||
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
|
||||
// revised). Direct receipts always use the entered cost.
|
||||
@@ -150,10 +157,13 @@ public sealed class GrnService : IGrnService
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
// Compare in base UOM: a GRN may legitimately receive in a different UOM from
|
||||
// the one the PO was raised in (10 BOX ordered, 120 PCS delivered), and
|
||||
// comparing the two raw numbers would reject that valid receipt.
|
||||
var openQtyBase = poLine.QtyBase - poLine.QtyReceivedBase;
|
||||
if (qtyBaseEntered > openQtyBase * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
$"Receiving {input.Qty} ({qtyBaseEntered} base) exceeds the open quantity {openQtyBase} base on PO line {input.PoLineId}.", 422);
|
||||
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
@@ -178,6 +188,8 @@ public sealed class GrnService : IGrnService
|
||||
BinId = input.BinId,
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
QtyBase = qtyBaseEntered,
|
||||
ConversionFactor = conversionFactor,
|
||||
UnitCost = unitCost,
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
@@ -234,10 +246,13 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
|
||||
// enters stock value (docs/10 FR-GRN-06, revised).
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
|
||||
// enters stock value (docs/10 FR-GRN-06, revised). Quantity and cost come from
|
||||
// the factor snapshotted at line creation, not a fresh lookup — see GrnLine.QtyBase.
|
||||
var qtyBase = line.QtyBase;
|
||||
var unitCostBase = line.ConversionFactor == 1m
|
||||
? line.NetUnitCost
|
||||
: Math.Round(line.NetUnitCost / line.ConversionFactor, 6, MidpointRounding.AwayFromZero);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
@@ -258,7 +273,13 @@ public sealed class GrnService : IGrnService
|
||||
if (line.PoLineId is not null)
|
||||
{
|
||||
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
|
||||
if (poLine is not null) poLine.QtyReceived += line.Qty;
|
||||
if (poLine is not null)
|
||||
{
|
||||
// Accrue in base so receipts in a UOM other than the PO's still add up.
|
||||
// QtyReceived is kept in the PO's own UOM for display only.
|
||||
poLine.QtyReceivedBase += qtyBase;
|
||||
poLine.QtyReceived = UomConverter.FromBase(poLine.QtyReceivedBase, poLine.ConversionFactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,22 +366,14 @@ public sealed class GrnService : IGrnService
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
|
||||
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
|
||||
/// identical, so receive costing is unchanged.
|
||||
/// </summary>
|
||||
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
||||
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
|
||||
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return;
|
||||
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
if (po is null) return;
|
||||
|
||||
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
|
||||
// Base-vs-base: QtyReceived is a denormalized display figure and must not gate closing.
|
||||
po.Status = po.Lines.All(l => l.QtyReceivedBase >= l.QtyBase)
|
||||
? PurchaseOrderStatus.FullyReceived
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
@@ -8,7 +9,7 @@ public interface IBundleSaleService
|
||||
{
|
||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
@@ -32,4 +33,29 @@ public interface IUomConverter
|
||||
/// consumes, not from the document).
|
||||
/// </summary>
|
||||
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The single factor lookup every other method is built on: <c>1</c> when
|
||||
/// <paramref name="uomId"/> is already the base UOM, otherwise the item's conversion
|
||||
/// factor from that UOM to base. Throws 422 when none is defined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Callers that persist a line should store this alongside the quantity so posting
|
||||
/// reads a snapshot instead of re-resolving — a factor edited between save and post
|
||||
/// must never change what an already-saved document posts.
|
||||
/// </remarks>
|
||||
Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Every UOM <paramref name="itemId"/> may be transacted in — base UOM first, then each
|
||||
/// conversion source, ordered by name.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Entry-time guard: throws 422 naming the allowed units when <paramref name="uomId"/>
|
||||
/// is neither the item's base UOM nor a UOM it has a conversion from. Call this when a
|
||||
/// document line is created so the user is told at entry, not by a cryptic failure at post.
|
||||
/// </summary>
|
||||
Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,15 @@ public sealed class ItemService : IItemService
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
// Conversions are stored as <other> → base. Repointing the base UOM would leave every
|
||||
// existing row aimed at a UOM that is no longer the base: invisible to IUomConverter,
|
||||
// filtered out of the allowed-UOM list, and unfixable through the conversions editor
|
||||
// (which would 422 on re-save). Make the user clear them deliberately instead.
|
||||
if (request.BaseUomId != item.BaseUomId && item.UomConversions.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item {item.Sku} has {item.UomConversions.Count} UOM conversion(s) defined against base UOM {item.BaseUomId}. " +
|
||||
"Remove them before changing the base UOM, then re-enter them against the new base.", 422);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
@@ -240,6 +249,26 @@ public sealed class ItemService : IItemService
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
|
||||
|
||||
// Conversions are one-directional: always <other> → base. IUomConverter looks up
|
||||
// exactly that shape and never inverts a factor, so a row saved the other way round
|
||||
// would persist happily, render in the UI, and then be invisible at post time. Reject
|
||||
// it here instead of letting it fail later as an unexplained 422.
|
||||
foreach (var c in request.Conversions)
|
||||
{
|
||||
if (c.ToUom != item.BaseUomId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Conversion {c.FromUom} → {c.ToUom} is invalid: conversions must convert to the item's base UOM ({item.BaseUomId}).", 422);
|
||||
if (c.FromUom == c.ToUom)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Conversion {c.FromUom} → {c.ToUom} is invalid: a UOM cannot convert to itself.", 422);
|
||||
if (c.FromUom == item.BaseUomId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
"The base UOM converts to itself implicitly (factor 1) and must not be listed.", 422);
|
||||
if (c.Factor <= 0m)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Conversion {c.FromUom} → {c.ToUom} must have a factor greater than zero.", 422);
|
||||
}
|
||||
|
||||
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
|
||||
item.UomConversions.Remove(stale);
|
||||
foreach (var input in request.Conversions)
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<ProductionRun> _runs;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
@@ -34,8 +35,9 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
||||
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
||||
IUnitOfWork uow, ICurrentUser currentUser)
|
||||
IUomConverter uomConverter, IUnitOfWork uow, ICurrentUser currentUser)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_templates = templates;
|
||||
_stages = stages;
|
||||
_inputs = inputs;
|
||||
@@ -291,6 +293,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
|
||||
// Existence is not enough: a stage line's UOM must be one its own item can convert to
|
||||
// base, or the run would fail with a 422 at stage start — long after the template was
|
||||
// authored. Check the (item, uom) pairing here, while the template is being saved.
|
||||
var itemUomPairs = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => (i.ItemId, i.UomId)).Concat(s.Outputs.Select(o => (o.ItemId, o.UomId))))
|
||||
.Distinct().ToList();
|
||||
|
||||
foreach (var (itemId, uomId) in itemUomPairs)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
if (item is not null)
|
||||
await _uomConverter.ValidateUomAsync(item, uomId, ct);
|
||||
}
|
||||
|
||||
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
|
||||
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||
var badKinds = request.Annotations
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -28,6 +29,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -35,8 +37,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
public PurchaseOrderService(
|
||||
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IUomConverter uomConverter, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_pos = pos;
|
||||
_vendors = vendors;
|
||||
_requisitions = requisitions;
|
||||
@@ -83,6 +86,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var lines = await ToLinesAsync(request.Lines, ct);
|
||||
|
||||
var po = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
@@ -98,7 +102,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(ToLine).ToList()
|
||||
Lines = lines
|
||||
};
|
||||
await _pos.AddAsync(entity, token);
|
||||
return entity;
|
||||
@@ -129,8 +133,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||
po.Lines.Clear();
|
||||
foreach (var input in request.Lines)
|
||||
po.Lines.Add(ToLine(input));
|
||||
foreach (var line in await ToLinesAsync(request.Lines, ct))
|
||||
po.Lines.Add(line);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -219,16 +223,34 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
|
||||
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
/// <summary>
|
||||
/// Materialises request lines, resolving each one's base quantity up front. Resolving here
|
||||
/// rejects a UOM the item cannot be ordered in at entry time, and gives GRN receipt matching
|
||||
/// a stable base figure to compare against regardless of the UOM the goods arrive in.
|
||||
/// </summary>
|
||||
private async Task<List<PoLine>> ToLinesAsync(IReadOnlyCollection<CreatePoLineInput> inputs, CancellationToken ct)
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
UnitPrice = l.UnitPrice,
|
||||
Tax = l.Tax,
|
||||
QtyReceived = 0
|
||||
};
|
||||
var lines = new List<PoLine>(inputs.Count);
|
||||
foreach (var l in inputs)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == l.ItemId, ct);
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, l.UomId, ct);
|
||||
lines.Add(new PoLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
QtyBase = UomConverter.ApplyFactor(l.Qty, factor),
|
||||
ConversionFactor = factor,
|
||||
UnitPrice = l.UnitPrice,
|
||||
Tax = l.Tax,
|
||||
QtyReceived = 0,
|
||||
QtyReceivedBase = 0
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
|
||||
{
|
||||
@@ -276,5 +298,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
||||
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
||||
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived,
|
||||
l.QtyBase, l.QtyReceivedBase, l.ConversionFactor)).ToList());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -32,9 +33,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
public SalesInvoiceService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_invoices = invoices;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
@@ -152,6 +154,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
// Resolve the base quantity now and store it on the line. This doubles as the
|
||||
// entry-time UOM check (an item that cannot be sold in this UOM throws 422 here,
|
||||
// while the user is still editing) and as the snapshot posting consumes — a
|
||||
// conversion factor edited later must not change what this document posts.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
|
||||
lines.Add(new SalesInvoiceLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -159,6 +167,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
// No IUomConverter here by design: every line arrives with its base quantity already
|
||||
// snapshotted by the service that saved it, so posting has nothing left to convert.
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesSlip> slips,
|
||||
@@ -57,18 +59,20 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
// Compare base against base: on-hand is base UOM, so the entered quantity would
|
||||
// under-report the requirement on any line not in the item's base UOM.
|
||||
var requestedQty = line.QtyBase + line.FreeQtyBase;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesInvoicePostingIssueDto(
|
||||
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
@@ -88,18 +92,19 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
// Base against base — see the matching comment in CheckInvoiceAsync.
|
||||
var requestedQty = line.QtyBase + line.FreeQtyBase;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesSlipPostingIssueDto(
|
||||
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
@@ -119,15 +124,17 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.QtyBase) continue;
|
||||
|
||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
issues.Add(new BundleSalePostingIssueDto(
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
line.QtyBase, available, line.QtyBase - available, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
@@ -139,10 +146,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesInvoice),
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
getDocId: x => x.SalesInvoiceId,
|
||||
ct: ct);
|
||||
|
||||
@@ -152,10 +159,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesSlip),
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
getDocId: x => x.SalesSlipId,
|
||||
ct: ct);
|
||||
|
||||
@@ -165,10 +172,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase, l.QtyBase, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(BundleSale),
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
getDocId: x => x.BundleSaleId,
|
||||
ct: ct);
|
||||
|
||||
@@ -210,5 +217,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A line reduced to what posting needs. Every quantity here is in the item's <b>base</b>
|
||||
/// UOM, taken from the snapshot the document service resolved at save — the FIFO engine
|
||||
/// accepts nothing else, and re-resolving at post time would let a factor edited in the
|
||||
/// meantime change what a saved document consumes.
|
||||
/// </summary>
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||
{
|
||||
private const decimal FreeIssueThreshold = 10m;
|
||||
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
@@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
|
||||
if (slip is null) return null;
|
||||
|
||||
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var freeIssueLines = slip.Lines
|
||||
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
|
||||
.ToList();
|
||||
|
||||
if (freeIssueLines.Count == 0)
|
||||
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
|
||||
|
||||
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var candidateItems = await _items.Query().AsNoTracking()
|
||||
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
@@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||
|
||||
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
||||
foreach (var line in freeIssueLines)
|
||||
{
|
||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||
|
||||
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
||||
if (freeQty <= 0m) continue;
|
||||
|
||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||
{
|
||||
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||
@@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.Qty,
|
||||
freeQty,
|
||||
FreeIssueThreshold,
|
||||
line.FreeQty,
|
||||
line.Qty,
|
||||
rewardOptions));
|
||||
}
|
||||
|
||||
@@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -33,9 +34,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
public SalesSlipService(
|
||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_slips = slips;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
@@ -176,6 +178,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
// Resolve the base quantity now and store it on the line — see the matching
|
||||
// comment in SalesInvoiceService.BuildLinesAsync.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
|
||||
lines.Add(new SalesSlipLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -183,6 +189,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -14,17 +14,32 @@ public sealed class StockService : IStockService
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<StockTransferLine> _transferLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
public StockService(
|
||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines,
|
||||
IRepository<Item> items)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_transferLines = transferLines;
|
||||
_items = items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base UOM (id + name) for a set of items, as one query. Stock reads are already
|
||||
/// set-based to avoid N+1; this keeps the UOM label on the same footing.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<int, (int Id, string Name)>> BaseUomsAsync(
|
||||
IReadOnlyCollection<int> itemIds, CancellationToken ct)
|
||||
=> (await _items.Query().AsNoTracking()
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => new { i.ItemId, i.BaseUomId, Name = i.BaseUom!.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => x.ItemId, x => (x.BaseUomId, x.Name));
|
||||
|
||||
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
||||
@@ -47,7 +62,10 @@ public sealed class StockService : IStockService
|
||||
const decimal reserved = 0m;
|
||||
var available = onHand - onHold - reserved;
|
||||
|
||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
||||
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
|
||||
return new StockOnHandDto(
|
||||
itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow,
|
||||
uom.Id, uom.Name ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,16 +118,20 @@ public sealed class StockService : IStockService
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
|
||||
var baseUoms = await BaseUomsAsync(itemIds, ct);
|
||||
|
||||
var asOf = DateTime.UtcNow;
|
||||
var rows = page.Select(p =>
|
||||
{
|
||||
var key = (p.ItemId, p.WarehouseId);
|
||||
var hold = onHold.GetValueOrDefault(key);
|
||||
var transit = inTransit.GetValueOrDefault(key);
|
||||
var uom = baseUoms.GetValueOrDefault(p.ItemId);
|
||||
const decimal reserved = 0m;
|
||||
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
|
||||
return new StockOnHandDto(
|
||||
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
|
||||
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf,
|
||||
uom.Id, uom.Name ?? string.Empty);
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -139,9 +161,20 @@ public sealed class StockService : IStockService
|
||||
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var baseUoms = await BaseUomsAsync(rows.Select(r => r.ItemId).Distinct().ToList(), ct);
|
||||
rows = rows.Select(r =>
|
||||
{
|
||||
var uom = baseUoms.GetValueOrDefault(r.ItemId);
|
||||
return r with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
|
||||
return valuation with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -11,28 +12,128 @@ namespace ERPCore.Services.Stock;
|
||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||
/// receive path keeps costing exactly as before.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Conversions are one-directional by design: a row always reads
|
||||
/// <c>FromUom → ToUom = item.BaseUomId</c>, enforced on write by
|
||||
/// <c>ItemService.UpdateUomConversionsAsync</c>. Nothing here inverts a factor, so a row
|
||||
/// stored in the opposite direction would be invisible to every caller — which is exactly
|
||||
/// why the write side rejects it rather than this side guessing.
|
||||
/// </remarks>
|
||||
public sealed class UomConverter : IUomConverter
|
||||
{
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
/// <summary>Quantity columns are <c>(18,4)</c> across the model.</summary>
|
||||
private const int QtyScale = 4;
|
||||
|
||||
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
|
||||
/// <summary>Unit-cost columns and <c>uom_conversions.Factor</c> are <c>(18,6)</c>.</summary>
|
||||
private const int CostScale = 6;
|
||||
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
|
||||
public UomConverter(IRepository<UomConversion> conversions, IRepository<Item> items, IRepository<Uom> uoms)
|
||||
{
|
||||
_conversions = conversions;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
}
|
||||
|
||||
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
var factor = await ResolveFactorAsync(item, uomId, ct);
|
||||
if (factor == 1m)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
// Quantity scales up by the factor, so the per-unit cost scales down by it —
|
||||
// total value is preserved.
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
// total value is preserved. Round to each column's own scale here rather than
|
||||
// letting the provider truncate on write, so what posts is what was computed.
|
||||
return (
|
||||
Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero),
|
||||
Math.Round(unitCostPerUom / factor, CostScale, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
||||
|
||||
public async Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return 1m;
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
|
||||
|
||||
if (conv is null)
|
||||
throw await NoConversionAsync(item, uomId, ct);
|
||||
|
||||
return conv.Factor;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.Select(i => new { i.ItemId, i.BaseUomId, BaseUomName = i.BaseUom!.Name })
|
||||
.FirstOrDefaultAsync(ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
var converted = await _conversions.Query().AsNoTracking()
|
||||
.Where(c => c.ItemId == itemId && c.ToUomId == item.BaseUomId && c.FromUomId != item.BaseUomId)
|
||||
.Select(c => new AllowedUomDto(c.FromUomId, c.FromUom!.Name, c.Factor, false))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Base first — it is what every form defaults to — then the alternates by name.
|
||||
return converted
|
||||
.OrderBy(u => u.Name)
|
||||
.Prepend(new AllowedUomDto(item.BaseUomId, item.BaseUomName, 1m, true))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return;
|
||||
|
||||
var exists = await _conversions.Query().AsNoTracking()
|
||||
.AnyAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
|
||||
|
||||
if (!exists)
|
||||
throw await NoConversionAsync(item, uomId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an already-resolved factor to a quantity, rounded to the quantity scale.
|
||||
/// Callers that snapshot a line use this so every base quantity in the system is
|
||||
/// derived and rounded identically.
|
||||
/// </summary>
|
||||
public static decimal ApplyFactor(decimal qty, decimal factor)
|
||||
=> Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero);
|
||||
|
||||
/// <summary>
|
||||
/// Restates a base quantity in <paramref name="factor"/>'s UOM. <b>Display only</b> —
|
||||
/// this divides, so it can drift, and its result must never reach a stock or ledger
|
||||
/// write. Conversion toward base is the authoritative direction.
|
||||
/// </summary>
|
||||
public static decimal FromBase(decimal qtyBase, decimal factor)
|
||||
=> factor == 0m ? 0m : Math.Round(qtyBase / factor, QtyScale, MidpointRounding.AwayFromZero);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 422 for an unconvertible UOM. Names the units the item actually accepts,
|
||||
/// because the bare id in the old message told the user nothing about how to recover.
|
||||
/// </summary>
|
||||
private async Task<DomainException> NoConversionAsync(Item item, int uomId, CancellationToken ct)
|
||||
{
|
||||
var attempted = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == uomId)
|
||||
.Select(u => u.Name)
|
||||
.FirstOrDefaultAsync(ct) ?? $"#{uomId}";
|
||||
|
||||
var allowed = await GetAllowedUomsAsync(item.ItemId, ct);
|
||||
|
||||
return new DomainException(ErrorCodes.Validation,
|
||||
$"Item {item.Sku} cannot be transacted in {attempted}. Allowed units: " +
|
||||
$"{string.Join(", ", allowed.Select(u => u.Name))}. " +
|
||||
"Add a UOM conversion on the item to use another unit.", 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ERPCore",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,11 @@ SCRIPTS = [
|
||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
# UOM engine. These run after the manufacturing scripts because they reuse the
|
||||
# SMOKE-PRD warehouse m4 creates, and they drain it before seeding their own stock.
|
||||
("UOM conversion direction is enforced", "uom_direction.py"),
|
||||
("UOM non-base sales consume converted qty", "uom_sales_nonbase.py"),
|
||||
("UOM cross-unit GRN against a PO", "uom_grn_po_cross.py"),
|
||||
]
|
||||
|
||||
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Smoke test — UOM conversions are one-directional, and the API says so.
|
||||
|
||||
`UomConverter` looks up exactly one shape, `FromUom -> ToUom = item.BaseUomId`, and never
|
||||
inverts a factor. `UpdateUomConversionsAsync` used to accept *any* pair, so saving the more
|
||||
natural-reading `base -> BOX` produced a row that returned 200, appeared in the item detail
|
||||
response, and was then silently invisible to every consumer — surfacing much later as
|
||||
"no UOM conversion" 422 at GRN confirm or stage start, on an item that visibly had one.
|
||||
|
||||
* base -> other is rejected with 422 (the direction that used to save and then not work)
|
||||
* other -> base is accepted
|
||||
* a self-conversion and a base-as-source row are rejected
|
||||
* a zero/negative factor is rejected (UomConverter divides unit cost by it)
|
||||
* changing an item's base UOM while conversions exist is refused rather than orphaning them
|
||||
|
||||
python Backend/smoke/uom_direction.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
item = c.get("/items?pageSize=1&status=Active").body["items"]
|
||||
if not item:
|
||||
sys.exit("FATAL: no active items.")
|
||||
item = item[0]
|
||||
item_id, base_uom = item["itemId"], item["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
other = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if other is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs.")
|
||||
print(f"item={item_id} baseUom={base_uom} otherUom={other}")
|
||||
|
||||
def put(conversions):
|
||||
return c.put(f"/items/{item_id}/uom-conversions", {"conversions": conversions})
|
||||
|
||||
chk.section("1. The correct direction is accepted")
|
||||
ok = put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
|
||||
chk.status("other -> base", ok, 200)
|
||||
if ok.status == 200:
|
||||
chk.check("stored with the base UOM as target", ok.body["conversions"][0]["toUom"], base_uom)
|
||||
|
||||
chk.section("2. The reverse direction is rejected, not silently stored")
|
||||
chk.status("base -> other", put([{"fromUom": base_uom, "toUom": other, "factor": 12}]), 422)
|
||||
|
||||
chk.section("3. Degenerate rows are rejected")
|
||||
chk.status("self-conversion (other -> other)", put([{"fromUom": other, "toUom": other, "factor": 2}]), 422)
|
||||
chk.status("zero factor", put([{"fromUom": other, "toUom": base_uom, "factor": 0}]), 422)
|
||||
chk.status("negative factor", put([{"fromUom": other, "toUom": base_uom, "factor": -3}]), 422)
|
||||
|
||||
chk.section("4. Base UOM cannot be repointed while conversions exist")
|
||||
# Restore a valid conversion first, so the guard has something to protect.
|
||||
put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
|
||||
head = c.get(f"/items/{item_id}")
|
||||
if head.status == 200:
|
||||
body = head.body
|
||||
moved = c.put(f"/items/{item_id}", {
|
||||
"sku": body["sku"], "name": body["name"], "description": body.get("description"),
|
||||
"categoryId": body["categoryId"], "subCategoryId": body.get("subCategoryId"),
|
||||
"brandId": body.get("brandId"),
|
||||
"baseUomId": other, # <- the repoint being guarded
|
||||
"defaultVendorId": body.get("defaultVendorId"),
|
||||
"stockNature": body["stockNature"], "trackingMode": body["trackingMode"],
|
||||
"taxClass": body.get("taxClass"), "salePrice": body.get("salePrice"),
|
||||
}, if_match=head.etag)
|
||||
chk.status("change base UOM with conversions defined", moved, 422)
|
||||
else:
|
||||
chk.check("could read the item for the repoint test", head.status, 200)
|
||||
|
||||
return chk.finish("UOM-DIRECTION")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Smoke test — receiving in a different UOM from the one ordered.
|
||||
|
||||
`GrnService` compared the GRN line's entered quantity against `poLine.Qty - poLine.QtyReceived`
|
||||
with no conversion, so a PO for 10 BOX receiving a legitimate 120 base units was rejected
|
||||
outright with OVER_RECEIPT_TOLERANCE — a user-visible false failure. It then accrued the GRN's
|
||||
quantity into `poLine.QtyReceived` (a PO-UOM field), and the close condition consumed that
|
||||
mixed-unit value, so a PO could close early or never close.
|
||||
|
||||
Both sides now run on the base pair (`QtyBase` / `QtyReceivedBase`), with `QtyReceived` kept
|
||||
as a denormalized display figure only.
|
||||
|
||||
* a receipt in base UOM against a PO raised in BOX is ACCEPTED
|
||||
* the FIFO layer and ledger record the base quantity
|
||||
* `qtyReceivedBase` accrues correctly and the PO reaches FullyReceived
|
||||
* over-receipt beyond tolerance is still rejected, now measured in base units
|
||||
|
||||
python Backend/smoke/uom_grn_po_cross.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, ensure_vendor
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
FACTOR = 12
|
||||
ORDER_BOXES = 10 # -> 120 base units
|
||||
RECEIVE_BASE = 120 # the whole order, expressed in base units
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
|
||||
if i["stockNature"] == "Stocked" and i["trackingMode"] == "None"), None)
|
||||
if item is None:
|
||||
sys.exit("FATAL: need a Stocked, untracked item.")
|
||||
base_uom = item["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if box_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs.")
|
||||
|
||||
vendor = ensure_vendor(c)
|
||||
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
|
||||
|
||||
chk.section("1. Conversion + a PO raised in BOX")
|
||||
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("define BOX -> base conversion", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
po = c.post("/purchase-orders", {
|
||||
"vendorId": vendor,
|
||||
"lines": [{"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": ORDER_BOXES, "unitPrice": 60, "tax": 0}],
|
||||
})
|
||||
chk.status("create the PO in BOX", po, 201)
|
||||
if po.status != 201:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
po_line = po.body["lines"][0]
|
||||
chk.check("PO line keeps the ordered qty in BOX", float(po_line["qty"]), float(ORDER_BOXES))
|
||||
chk.check("PO line snapshots the base quantity", float(po_line["qtyBase"]), float(ORDER_BOXES * FACTOR))
|
||||
chk.check("PO line snapshots the factor", float(po_line["conversionFactor"]), float(FACTOR))
|
||||
|
||||
drain_stock(c, wh)
|
||||
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
chk.section("2. Receiving the order in BASE units is accepted")
|
||||
grn = c.post("/grns", {
|
||||
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
|
||||
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
|
||||
"uomId": base_uom, # <- different UOM from the PO
|
||||
"qty": RECEIVE_BASE, "unitCost": 5, "discountPct": 0, "vatPct": 0}],
|
||||
})
|
||||
# This is the assertion that fails on the old code: it returned 422 OVER_RECEIPT_TOLERANCE.
|
||||
chk.status("GRN in base UOM against a BOX purchase order", grn, 201)
|
||||
if grn.status != 201:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
||||
chk.status("confirm the GRN", confirmed, 200)
|
||||
if confirmed.status != 200:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
chk.check("on-hand rose by the base quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before + RECEIVE_BASE)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=GRN&sourceDocId={grn.body['grnId']}&pageSize=50").body["items"]
|
||||
chk.check("one GRN ledger row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the received base quantity", float(rows[0]["qtyBase"]), float(RECEIVE_BASE))
|
||||
|
||||
chk.section("3. The PO closes on the base pair")
|
||||
reread = c.get(f"/purchase-orders/{po.body['poId']}")
|
||||
chk.status("re-read the PO", reread, 200)
|
||||
if reread.status == 200:
|
||||
rl = reread.body["lines"][0]
|
||||
chk.check("qtyReceivedBase accrued in base units", float(rl["qtyReceivedBase"]), float(RECEIVE_BASE))
|
||||
chk.check("qtyReceived shown back in the PO's own UOM", float(rl["qtyReceived"]), float(ORDER_BOXES))
|
||||
chk.check("PO is FullyReceived", reread.body["status"], "FullyReceived")
|
||||
|
||||
chk.section("4. Over-receipt is still rejected, measured in base")
|
||||
over = c.post("/grns", {
|
||||
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
|
||||
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
|
||||
"uomId": base_uom, "qty": RECEIVE_BASE, "unitCost": 5,
|
||||
"discountPct": 0, "vatPct": 0}],
|
||||
})
|
||||
chk.status("receiving the whole order again", over, 422, "OVER_RECEIPT_TOLERANCE")
|
||||
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Smoke test — selling in a non-base UOM consumes the converted quantity.
|
||||
|
||||
This is the regression test for the UOM engine's worst defect. `SalesPostingService`
|
||||
injected `IUomConverter` and never called it: `PostAsync` fed the *entered* line quantity
|
||||
straight into `IFifoCostingService.ConsumeAsync`, whose contract is base UOM only. Selling
|
||||
2 BOX of a 12-per-box item therefore removed 2 base units instead of 24 and wrote
|
||||
`StockLedger.QtyBase = 2` into a column defined as base — overstating stock, understating
|
||||
COGS, and drifting the ledger's running balance away from the layer sum.
|
||||
|
||||
`m4b_uom_conversion.py` covered exactly the same hazard on the *production* path, which is
|
||||
why that path was correct and this one was not. This script closes the gap:
|
||||
|
||||
* an invoice line in a non-base UOM consumes qty x factor base units
|
||||
* the ledger records the BASE quantity
|
||||
* the line still reports the ENTERED qty and UOM, so the printed document says "2 BOX"
|
||||
* the pre-post check reports the shortfall in base units (it compared entered vs base
|
||||
on-hand before, and answered "can post" when it could not)
|
||||
* the same holds for a sales slip, which shares PostAsync
|
||||
* a UOM the item has no conversion for is refused at line creation, not at post
|
||||
|
||||
python Backend/smoke/uom_sales_nonbase.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
FACTOR = 12 # 1 BOX = 12 base units
|
||||
SELL_BOXES = 2 # -> 24 base units
|
||||
SEED_BASE = 100 # base units on hand before selling
|
||||
UNIT_COST = 5.0
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
|
||||
if i["stockNature"] == "Stocked"), None)
|
||||
if item is None:
|
||||
sys.exit("FATAL: need a Stocked item.")
|
||||
base_uom = item["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if box_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||
|
||||
customer = c.get("/customers?pageSize=1").body["items"]
|
||||
if not customer:
|
||||
sys.exit("FATAL: no customers seeded.")
|
||||
customer_id = customer[0]["customerId"]
|
||||
|
||||
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
|
||||
|
||||
# --- fixtures ---------------------------------------------------------
|
||||
chk.section("1. Conversion + known on-hand")
|
||||
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("define BOX -> base conversion", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
allowed = c.get(f"/items/{item['itemId']}/uoms")
|
||||
chk.status("GET /items/{id}/uoms", allowed, 200)
|
||||
if allowed.status == 200:
|
||||
ids = [u["uomId"] for u in allowed.body]
|
||||
chk.check("allowed UOMs are base + the conversion source", sorted(ids), sorted([base_uom, box_uom]))
|
||||
chk.check("base UOM is flagged and listed first", allowed.body[0]["isBase"], True)
|
||||
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(item["itemId"], base_uom, SEED_BASE, UNIT_COST)])
|
||||
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.check(f"on-hand seeded to {SEED_BASE} base units", before, float(SEED_BASE))
|
||||
|
||||
on_hand = c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body
|
||||
chk.check("stock read is labelled with the base UOM", on_hand["baseUomId"], base_uom)
|
||||
chk.check("stock read carries the base UOM name", bool(on_hand["baseUomName"]), True)
|
||||
|
||||
# --- invoice in BOX ---------------------------------------------------
|
||||
chk.section("2. An invoice line entered in BOX")
|
||||
expected_base = SELL_BOXES * FACTOR # 2 x 12 = 24
|
||||
|
||||
inv = c.post("/sales-invoices", {
|
||||
"customerId": customer_id,
|
||||
"warehouseId": wh,
|
||||
"invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
chk.status("create the invoice", inv, 201)
|
||||
if inv.status != 201:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
line = inv.body["lines"][0]
|
||||
chk.check("line keeps the ENTERED qty (prints as 2 BOX)", float(line["qty"]), float(SELL_BOXES))
|
||||
chk.check("line keeps the ENTERED uom", line["uomId"], box_uom)
|
||||
|
||||
check = c.get(f"/sales-invoices/{inv.body['salesInvoiceId']}/posting-check")
|
||||
chk.status("posting check", check, 200)
|
||||
if check.status == 200:
|
||||
chk.check("posting check passes with enough stock", check.body["canPost"], True)
|
||||
|
||||
chk.section("3. Posting consumes the CONVERTED quantity")
|
||||
posted = c.post(f"/sales-invoices/{inv.body['salesInvoiceId']}/post")
|
||||
chk.status("post the invoice", posted, 200)
|
||||
if posted.status != 200:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
after = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.check(f"on-hand fell by {expected_base} base units, not {SELL_BOXES}", after, before - expected_base)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=SINV&sourceDocId={inv.body['salesInvoiceId']}&pageSize=50").body["items"]
|
||||
chk.check("one ledger row for the invoice", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the CONVERTED quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||
chk.check("ledger row is labelled with the base UOM", rows[0]["baseUomId"], base_uom)
|
||||
|
||||
# --- the same on a slip ----------------------------------------------
|
||||
chk.section("4. A sales slip behaves identically (shared PostAsync)")
|
||||
before_slip = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
slip = c.post("/sales-slips", {
|
||||
"customerId": customer_id,
|
||||
"warehouseId": wh,
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
chk.status("create the slip", slip, 201)
|
||||
if slip.status == 201:
|
||||
chk.status("post the slip", c.post(f"/sales-slips/{slip.body['salesSlipId']}/post"), 200)
|
||||
chk.check("slip also consumed the converted quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before_slip - expected_base)
|
||||
|
||||
# --- snapshot immutability -------------------------------------------
|
||||
chk.section("5. A factor edited after save does not change what posts")
|
||||
draft = c.post("/sales-invoices", {
|
||||
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
if draft.status == 201:
|
||||
# Double the factor *after* the draft is saved.
|
||||
c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR * 2}]})
|
||||
before_snap = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.status("post the pre-existing draft", c.post(f"/sales-invoices/{draft.body['salesInvoiceId']}/post"), 200)
|
||||
chk.check(f"posted the snapshotted {FACTOR}, not the edited {FACTOR * 2}",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before_snap - FACTOR)
|
||||
# Restore for re-runnability.
|
||||
c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
else:
|
||||
chk.check("could create the snapshot-test draft", draft.status, 201)
|
||||
|
||||
# --- unusable UOM refused at entry ------------------------------------
|
||||
chk.section("6. A UOM with no conversion is refused at line creation")
|
||||
third = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||
if u["uomId"] not in (base_uom, box_uom)), None)
|
||||
if third is None:
|
||||
chk.check("skipped: need a third UOM", True, True)
|
||||
else:
|
||||
bad = c.post("/sales-invoices", {
|
||||
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": third, "warehouseId": wh,
|
||||
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
# The point is that this fails at CREATE (while the user is editing), not at post.
|
||||
chk.status("invoice line in an unconvertible UOM", bad, 422)
|
||||
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -129,6 +129,8 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
||||
>
|
||||
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
||||
>
|
||||
> **2026-08-05 — Cheque Management status/type fields were rendering as raw integers, not names (user-reported + confirmed with GL's own `06_Enums_Reference.md`).** That doc's key fact: GL has no global `JsonStringEnumConverter`. A JSON-**body** enum field (e.g. the Issue-cheque form's `payeeType`) is independently declared `string` server-side and parsed via `Enum.TryParse`, and a query-string enum filter binds natively by name — both already correct here, unaffected. But `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are genuine enum-typed properties on GL's own **response** DTOs, backed by real `integer` DB columns — with no converter, GL's JSON serializes each one as its raw number (`1`/`2`/`3`/...), not its name. This wasn't just a cosmetic label bug: every list badge, the dialogs' status-based available-actions logic, and any `===` comparison against this frontend's own string enums (`ChequeBookStatus.Active`, etc.) would have silently mismatched against these numbers. Fixed at the API boundary, not scattered across every consumer: added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (one per affected field, keyed by the exact integers `06_Enums_Reference.md` documents), and applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing GL's actual `number`/`number | null` response shape for these fields) plus `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one — so every page/dialog/badge map keeps working against the same string values as before, unchanged. Cross-checked every other enum in that doc's "Persisted enums" table against this frontend (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) — none are consumed anywhere in this app, confirming Cheque Management was the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
|
||||
## 7. UX states
|
||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||
|
||||
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
|
||||
emergencyContactName: employee.emergencyContactName,
|
||||
emergencyContactRelationship: employee.emergencyContactRelationship,
|
||||
emergencyContactPhone: employee.emergencyContactPhone,
|
||||
hireDate: employee.hireDate,
|
||||
confirmationDate: employee.confirmationDate,
|
||||
lastWorkingDate: employee.lastWorkingDate,
|
||||
departmentId: employee.departmentId,
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -112,8 +112,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
function itemFor(itemId: number | null) {
|
||||
return items.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
function uomLabel(uomId: number) {
|
||||
return uomName(uomId, uoms)
|
||||
}
|
||||
function warehouseCode(warehouseId: number) {
|
||||
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
||||
@@ -195,10 +195,10 @@ export default function PurchaseOrderDetailPage() {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -283,9 +283,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving�" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
@@ -377,7 +377,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.uomId ? uomLabel(line.uomId) : "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -14,14 +14,25 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -43,10 +54,10 @@ function newKey() {
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
||||
// from an RFQ keeps its negotiated price (below).
|
||||
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||
// there) and stays off this form, though it remains on the payload since the backend line
|
||||
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||
// from its negotiated price (below) but stays editable.
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
}
|
||||
@@ -72,23 +83,86 @@ function NewPurchaseOrderContent() {
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
|
||||
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
|
||||
|
||||
function loadItems() {
|
||||
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
|
||||
}
|
||||
function loadVendors() {
|
||||
return vendorsApi.list({ pageSize: 200, status: "Active" }).then((ve) => setVendors(ve.items))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
warehousesApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
])
|
||||
.then(([it, uo, wh, ve]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouses(wh.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
loadItems(),
|
||||
// pageSize: the default page would silently truncate the unit list as the master grows.
|
||||
uomsApi.list({ pageSize: 200 }).then((uo) => setUoms(uo.items)),
|
||||
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||
loadVendors(),
|
||||
]).catch((err) => setLoadError(errorMessage(err)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// A new item is created on the standalone item builder (too many fields for a modal here),
|
||||
// typically in another tab — refetch on refocus so it shows up in the line pickers without
|
||||
// the user having to reload this page and lose their draft.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
loadItems().catch(() => {})
|
||||
}
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function resetVendorForm() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
|
||||
async function handleCreateVendor() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||
setVErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setVSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({
|
||||
code: generatedVendorCode,
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
setVendorId(result.data.vendorId)
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setVendorDialogOpen(false)
|
||||
resetVendorForm()
|
||||
} catch (err) {
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setVSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
@@ -236,43 +310,72 @@ function NewPurchaseOrderContent() {
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor code</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor code" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor name</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:col-span-1">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: `${v.code} — ${v.name}`, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||
<DialogTrigger
|
||||
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!vErrors.name}>
|
||||
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
|
||||
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
|
||||
{vSubmitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{requisitionId && (
|
||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||
@@ -287,21 +390,34 @@ function NewPurchaseOrderContent() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard/products/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
title="Opens in a new tab — the item list here refreshes when you come back"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
New item
|
||||
</Link>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -313,11 +429,25 @@ function NewPurchaseOrderContent() {
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<Select<number | null>
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => {
|
||||
// Reset to the item's base unit and load its allowed units.
|
||||
allowedUoms.load(v)
|
||||
updateLine(line.key, {
|
||||
itemId: v,
|
||||
uomId: (items ?? []).find((i) => i.itemId === v)?.baseUomId ?? null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-11! w-full text-base"
|
||||
aria-invalid={!!errors.itemId}
|
||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -338,7 +468,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -374,6 +504,18 @@ function NewPurchaseOrderContent() {
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [approvingId, setApprovingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setDeletingId(po.poId)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
toast.success("Draft deleted", po.docNo)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
||||
setApprovingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setApprovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
@@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pos.map((po) => (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{pos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="View"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{editable && (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Approve draft"
|
||||
title="Approve draft"
|
||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||
onClick={() => handleApprove(po)}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||
onClick={() => handleDelete(po)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
||||
+253
-246
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import {
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from "./types"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -45,7 +48,8 @@ function QtyRow({
|
||||
}: {
|
||||
qty: number
|
||||
uomId: number | null
|
||||
uoms: Uom[]
|
||||
/** Already narrowed to the row's item by the caller — see `pickerOptions`. */
|
||||
uoms: { uomId: number; name: string }[]
|
||||
readOnly: boolean
|
||||
onQtyChange: (qty: number) => void
|
||||
onUomChange: (uomId: number) => void
|
||||
@@ -124,9 +128,12 @@ export function StageEditorPanel({
|
||||
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
|
||||
}
|
||||
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
|
||||
function pickInputItem(input: BuilderInput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
|
||||
}
|
||||
|
||||
@@ -145,6 +152,7 @@ export function StageEditorPanel({
|
||||
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
|
||||
function pickOutputItem(output: BuilderOutput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateOutput(output.key, {
|
||||
itemId,
|
||||
name: item?.name ?? output.name,
|
||||
@@ -175,265 +183,264 @@ export function StageEditorPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stage editor</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={pickerOptions(allowedUoms.get(input.itemId), uoms)}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={pickerOptions(allowedUoms.get(output.itemId), uoms)}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
|
||||
return new Set(named.map((n) => n.id))
|
||||
}
|
||||
|
||||
export default function TemplateBuilderPage() {
|
||||
function TemplateBuilderContent() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -765,46 +765,52 @@ export default function TemplateBuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TemplateBuilderPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
|
||||
<TemplateBuilderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
// Per-item UOM conversion editor (FR-MD-02).
|
||||
//
|
||||
// Until this panel existed, `PUT /items/{id}/uom-conversions` had no caller anywhere in the
|
||||
// app: conversions were typed, and had an API client method, but no screen could create one.
|
||||
// That made every non-base UOM unusable — a user could pick "Box-12" on an invoice line and
|
||||
// only discover at post time that the item had no conversion for it.
|
||||
//
|
||||
// Direction is fixed and not user-editable: a row always converts **into** the item's base
|
||||
// UOM. The server enforces that (`toUom` must equal `baseUomId`), because the conversion
|
||||
// engine only ever looks up `<other> → base` and never inverts a factor. Rendering `toUom`
|
||||
// as fixed text rather than a second picker is what keeps the two in step.
|
||||
import { useState } from "react"
|
||||
import { Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateConversionLine } from "@/lib/validations/master-data"
|
||||
import { UomConversion } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftRow {
|
||||
key: string
|
||||
fromUom: number | null
|
||||
factor: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
itemId: number
|
||||
baseUomId: number
|
||||
conversions: UomConversion[]
|
||||
uoms: { uomId: number; name: string }[]
|
||||
/** Lets the parent refresh the item so `conversions` stays in sync after a save. */
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
let rowSeq = 0
|
||||
const nextKey = () => `conv-${rowSeq++}`
|
||||
|
||||
export function UomConversionsPanel({ itemId, baseUomId, conversions, uoms, onSaved }: Props) {
|
||||
const [rows, setRows] = useState<DraftRow[]>(() =>
|
||||
conversions.map((c) => ({ key: nextKey(), fromUom: c.fromUom, factor: String(c.factor) })),
|
||||
)
|
||||
const [errors, setErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const baseName = uoms.find((u) => u.uomId === baseUomId)?.name ?? "base UOM"
|
||||
// The base UOM converts to itself implicitly; offering it here would only produce a 422.
|
||||
const selectableUoms = uoms.filter((u) => u.uomId !== baseUomId)
|
||||
|
||||
function updateRow(key: string, patch: Partial<DraftRow>) {
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { key: nextKey(), fromUom: null, factor: "" }])
|
||||
}
|
||||
|
||||
function removeRow(key: string) {
|
||||
setRows((prev) => prev.filter((r) => r.key !== key))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const nextErrors: Record<string, Record<string, string>> = {}
|
||||
for (const row of rows) {
|
||||
const rowErrors = validateConversionLine({ fromUom: row.fromUom, toUom: baseUomId, factor: row.factor })
|
||||
if (Object.keys(rowErrors).length > 0) nextErrors[row.key] = rowErrors
|
||||
}
|
||||
|
||||
// The unique index is on (item, from, to); catching it here beats a 400 from the server.
|
||||
const chosen = rows.map((r) => r.fromUom).filter((u): u is number => u !== null)
|
||||
const duplicates = chosen.filter((u, i) => chosen.indexOf(u) !== i)
|
||||
for (const row of rows) {
|
||||
if (row.fromUom !== null && duplicates.includes(row.fromUom)) {
|
||||
nextErrors[row.key] = { ...nextErrors[row.key], fromUom: "One conversion per unit" }
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// A full replace, matching the server's upsert semantics: an empty list clears them all,
|
||||
// which is also how a user detaches conversions before changing the base UOM.
|
||||
await itemsApi.updateUomConversions(itemId, {
|
||||
conversions: rows.map((r) => ({ fromUom: r.fromUom as number, toUom: baseUomId, factor: Number(r.factor) })),
|
||||
})
|
||||
toast.success("Conversions saved", `${rows.length} conversion(s) against ${baseName}`)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error("Could not save conversions", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-base font-semibold">UOM conversions</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Units this item can be bought, sold, or produced in besides {baseName}. Each row says how many{" "}
|
||||
{baseName} one of that unit is worth — a Box of 12 pieces is a factor of 12. Stock is always stored in{" "}
|
||||
{baseName}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversions — this item can only be transacted in {baseName}.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-64">Unit</TableHead>
|
||||
<TableHead className="w-40 text-right">Factor</TableHead>
|
||||
<TableHead>Converts to</TableHead>
|
||||
<TableHead className="w-16" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<Select<number | null>
|
||||
value={row.fromUom}
|
||||
onValueChange={(v) => updateRow(row.key, { fromUom: v })}
|
||||
items={selectableUoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors[row.key]?.fromUom}>
|
||||
<SelectValue placeholder="Select unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableUoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors[row.key]?.fromUom ? { message: errors[row.key].fromUom } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.000001"
|
||||
value={row.factor}
|
||||
onChange={(e) => updateRow(row.key, { factor: e.target.value })}
|
||||
aria-invalid={!!errors[row.key]?.factor}
|
||||
className="text-right"
|
||||
/>
|
||||
<FieldError errors={[errors[row.key]?.factor ? { message: errors[row.key].factor } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="text-base text-muted-foreground">
|
||||
{row.factor && Number(row.factor) > 0
|
||||
? `1 ${uoms.find((u) => u.uomId === row.fromUom)?.name ?? "unit"} = ${Number(row.factor)} ${baseName}`
|
||||
: baseName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeRow(row.key)} aria-label="Remove conversion">
|
||||
<Trash2 className="size-5 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-3">
|
||||
<Button variant="outline" onClick={addRow}>
|
||||
<Plus className="size-5" />
|
||||
Add conversion
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save conversions"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { UomConversionsPanel } from "./UomConversionsPanel"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -92,7 +95,9 @@ export default function ItemDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
load()
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
|
||||
// pageSize matters here: the default page would silently truncate the UOM list as the
|
||||
// master grows, hiding units from the base-UOM picker and the conversions editor.
|
||||
Promise.all([categoriesApi.list(), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([cat, uo, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
@@ -155,9 +160,6 @@ export default function ItemDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
|
||||
if (loadError && !item) {
|
||||
return (
|
||||
@@ -188,7 +190,7 @@ export default function ItemDetailPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
@@ -321,8 +323,19 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).
|
||||
{uomName(item.baseUomId, uoms)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).
|
||||
</p>
|
||||
|
||||
<UomConversionsPanel
|
||||
// Remount when the saved set changes so the editor's draft rows re-seed from the
|
||||
// server's response rather than keeping stale local state after a save or reload.
|
||||
key={item.conversions.map((c) => `${c.conversionId}:${c.factor}`).join("|")}
|
||||
itemId={item.itemId}
|
||||
baseUomId={item.baseUomId}
|
||||
conversions={item.conversions}
|
||||
uoms={uoms}
|
||||
onSaved={load}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,15 @@ export default function BrandsPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function ItemsPage() {
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "luci
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -57,7 +58,7 @@ export default function GrnDetailPage() {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
function uomFor(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
return uomName(uomId, uoms)
|
||||
}
|
||||
function binFor(binId: number | null) {
|
||||
if (!binId) return "—"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
@@ -108,12 +110,14 @@ export default function NewGrnPage() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [refreshingItems, setRefreshingItems] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
// pageSize: without it the default page silently truncates the unit list.
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
@@ -155,13 +159,18 @@ export default function NewGrnPage() {
|
||||
setHeaderError(null)
|
||||
try {
|
||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
||||
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
||||
// Open-ness is decided on the base pair, matching the server's close condition —
|
||||
// qty/qtyReceived are PO-UOM display figures derived by division and can drift.
|
||||
const openLines = po.lines.filter((l) => l.qtyReceivedBase < l.qtyBase)
|
||||
if (openLines.length === 0) {
|
||||
setHeaderError("This purchase order has no open (unreceived) lines.")
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// The receiving lines keep the PO's UOM, so preload each item's allowed units for the
|
||||
// few lines a user may switch to a different pack size.
|
||||
openLines.forEach((l) => allowedUoms.load(l.itemId))
|
||||
setLines(
|
||||
openLines.map(
|
||||
(l): DraftLine => ({
|
||||
@@ -440,220 +449,235 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null>
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => {
|
||||
// Reset the unit to the item's base and load its allowed
|
||||
// units — a UOM carried over from the previous item would
|
||||
// usually have no conversion for the new one.
|
||||
allowedUoms.load(v)
|
||||
updateLine(line.key, { itemId: v, uomId: itemFor(v)?.baseUomId ?? null })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uomName(line.uomId, uoms ?? [])}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{/* Shows what will actually hit stock when receiving in a pack unit. */}
|
||||
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -16,7 +18,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||
import { BundleSale, BundleSaleTemplate, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -26,6 +28,18 @@ import { ManagedUser } from "@/types/users"
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
uomId: templateLine?.uomId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
function statusClass(status: BundleSale["status"]) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
@@ -43,7 +57,9 @@ export default function BundleSaleDetailPage() {
|
||||
const bundleSaleId = Number(params.id)
|
||||
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
@@ -89,11 +105,15 @@ export default function BundleSaleDetailPage() {
|
||||
setTemplateId(data.bundleSaleTemplateId)
|
||||
setBundleName(data.bundleName)
|
||||
setBundlePrice(data.bundlePrice)
|
||||
data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||
setLines(
|
||||
data.lines.map((line) => ({
|
||||
key: `${line.bundleSaleLineId}`,
|
||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
// Keep the UOM the line was saved with. This used to be forced back to the
|
||||
// item's base because the server rewrote it that way; it now preserves what
|
||||
// was entered, so overriding here would discard the user's choice.
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
@@ -104,7 +124,12 @@ export default function BundleSaleDetailPage() {
|
||||
)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [bundleSaleId, params.id])
|
||||
}, [bundleSaleId, params.id, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) return
|
||||
bundleApi.getTemplate(templateId).then(setTemplate).catch((err) => setError(errorMessage(err)))
|
||||
}, [templateId])
|
||||
|
||||
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
@@ -116,9 +141,9 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1]
|
||||
const source = template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
||||
setLines((prev) => [...prev, createBlankLine(source)])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -170,6 +195,20 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const shortageIssues = bundle && bundle.status === "Draft"
|
||||
? bundle.lines
|
||||
.filter((line) => line.includeInBundle)
|
||||
.filter((line) => Number(line.qty || 0) > 0)
|
||||
.map((line) => ({
|
||||
bundleSaleLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
|
||||
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
|
||||
warehouseId: line.warehouseId,
|
||||
requestedQty: line.qty,
|
||||
}))
|
||||
: []
|
||||
|
||||
async function cancelBundle() {
|
||||
if (!bundle) return
|
||||
setBusy(true)
|
||||
@@ -236,6 +275,47 @@ export default function BundleSaleDetailPage() {
|
||||
|
||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||
|
||||
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Item</th>
|
||||
<th className="py-1 pr-3">Warehouse</th>
|
||||
<th className="py-1 pr-3 text-right">Requested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortageIssues.map((issue) => (
|
||||
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{issue.itemSku}</div>
|
||||
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -304,7 +384,8 @@ export default function BundleSaleDetailPage() {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||
}} disabled={!editing || !isDraft}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
@@ -319,12 +400,14 @@ export default function BundleSaleDetailPage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft}>
|
||||
{/* Enabled now that bundle lines keep the entered UOM (the server
|
||||
snapshots QtyBase beside it instead of overwriting Qty). */}
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft || !line.itemId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
@@ -332,12 +415,39 @@ export default function BundleSaleDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right w-28">
|
||||
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" />
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{editing && isDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium",
|
||||
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{line.includeInBundle ? "Included" : "Excluded"}
|
||||
</button>
|
||||
) : (
|
||||
line.includeInBundle ? "Included" : "Excluded"
|
||||
)}
|
||||
</TableCell>
|
||||
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add line to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -12,10 +12,13 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -26,9 +29,19 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary,
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
uomId: templateLine?.uomId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
export default function NewBundleSalePage() {
|
||||
function NewBundleSaleContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const templateFromQuery = searchParams.get("templateId")
|
||||
@@ -50,6 +63,7 @@ export default function NewBundleSalePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -80,10 +94,21 @@ export default function NewBundleSalePage() {
|
||||
if (!templateId) return
|
||||
bundleApi.getTemplate(templateId).then((res) => {
|
||||
setTemplate(res)
|
||||
setLines(res.lines.map(blankLine))
|
||||
setLines(
|
||||
res.lines.length > 0
|
||||
? res.lines.map((line) => {
|
||||
// Load each component's allowed units so the picker narrows for template-seeded rows too.
|
||||
allowedUoms.load(line.itemId)
|
||||
// Keep the template's own UOM. Forcing it to the item's base while keeping the
|
||||
// template's unitPrice would leave qty and price in different units, and
|
||||
// componentSubtotal below multiplies the two.
|
||||
return createBlankLine({ ...line, uomId: line.uomId })
|
||||
})
|
||||
: [createBlankLine()]
|
||||
)
|
||||
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||
}, [templateId])
|
||||
}, [items, templateId])
|
||||
|
||||
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
@@ -93,9 +118,7 @@ export default function NewBundleSalePage() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1] ?? template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, blankLine(source)])
|
||||
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -150,6 +173,22 @@ export default function NewBundleSalePage() {
|
||||
|
||||
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Need stock before saving this bundle?</div>
|
||||
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -221,13 +260,15 @@ export default function NewBundleSalePage() {
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="min-w-72">
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? line.uomId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
@@ -242,12 +283,14 @@ export default function NewBundleSalePage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })}>
|
||||
{/* Enabled now that bundle lines keep the entered UOM: the server
|
||||
snapshots the base quantity beside it instead of overwriting qty. */}
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!line.itemId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
@@ -255,14 +298,39 @@ export default function NewBundleSalePage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right w-28">
|
||||
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" />
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium",
|
||||
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{line.includeInBundle ? "Included" : "Excluded"}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add component to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -286,3 +354,11 @@ export default function NewBundleSalePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewBundleSalePage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<NewBundleSaleContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,18 @@ export default function BundleSalesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, status, query, customerId, warehouseId])
|
||||
|
||||
const visibleRows = useMemo(() => rows ?? [], [rows])
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery =
|
||||
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
}) ?? [],
|
||||
[rows, query, status]
|
||||
)
|
||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
||||
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
||||
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
||||
@@ -125,7 +136,12 @@ export default function BundleSalesPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by bundle, code, or customer"
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { pickerOptions, uomName as resolveUomName } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -70,6 +72,7 @@ export default function NewFreeIssuePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
async function refreshRows() {
|
||||
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
||||
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
|
||||
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
|
||||
itemName: item?.name ?? firstLine?.description ?? "—",
|
||||
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
|
||||
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
|
||||
uomName: uom?.name ?? resolveUomName(firstLine?.uomId, uoms),
|
||||
qty: firstLine?.qty ?? 0,
|
||||
freeQty: firstLine?.freeQty ?? 0,
|
||||
} satisfies FreeIssueRow
|
||||
@@ -146,11 +149,13 @@ export default function NewFreeIssuePage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
}
|
||||
|
||||
function selectEditingItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
}
|
||||
|
||||
@@ -325,7 +330,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -390,7 +395,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -11,6 +11,8 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
@@ -75,6 +77,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) {
|
||||
@@ -99,6 +102,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setInvoiceType(doc.data.invoiceType)
|
||||
// Preload allowed units for the items already on the document, so editing an
|
||||
// existing line offers the narrowed list immediately.
|
||||
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
@@ -150,6 +156,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
@@ -157,6 +164,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||
}
|
||||
@@ -181,14 +193,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
customerId,
|
||||
warehouseId,
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
@@ -309,7 +321,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||
<div className="text-sm text-muted-foreground">Location: {warehouse?.location ?? "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
@@ -349,7 +360,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
|
||||
<td className="px-4 py-3">{uomName(line.uomId, uoms)}</td>
|
||||
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
|
||||
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
|
||||
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
|
||||
@@ -379,8 +390,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="border-t pt-5">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
@@ -433,6 +455,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving..." : "Save invoice"}
|
||||
</button>
|
||||
{postingCheck && !postingCheck.canPost ? (
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Resolve stock in GRN
|
||||
</Link>
|
||||
) : null}
|
||||
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<Send className="size-4" />
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||
@@ -479,7 +510,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">UOM</option>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<option key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</option>
|
||||
@@ -495,17 +526,22 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-32">
|
||||
<input
|
||||
type="number"
|
||||
@@ -515,6 +551,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-24 text-right">
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Printer } from "lucide-react"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</TableCell>
|
||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -71,6 +73,7 @@ export default function NewSalesInvoicePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -129,6 +132,9 @@ export default function NewSalesInvoicePage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
// Load the units this item can actually be sold in, so the UOM picker below narrows from
|
||||
// the global list to base + defined conversions.
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
@@ -363,7 +369,7 @@ export default function NewSalesInvoicePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -380,6 +386,12 @@ export default function NewSalesInvoicePage() {
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
{/* Makes the conversion visible while editing instead of at post time. */}
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
@@ -400,6 +412,11 @@ export default function NewSalesInvoicePage() {
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -20,7 +20,7 @@ function formatHeader(key: string) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatCell(value: unknown) {
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
|
||||
if (typeof value === "string") {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -71,6 +73,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(slipId)) {
|
||||
@@ -97,6 +100,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setCashierUserId(doc.data.cashierUserId)
|
||||
setPromotionSuggestion(null)
|
||||
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesSlipLineId),
|
||||
@@ -149,12 +153,21 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
// Reset to the new item's base unit: a UOM carried over from the previous item
|
||||
// usually has no conversion for this one, and would be refused on save.
|
||||
uomId: items.find((i) => i.itemId === itemId)?.baseUomId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
@@ -172,14 +185,14 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
@@ -335,10 +348,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell className="px-4 py-3">{uomName(line.uomId, uoms)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
||||
</TableRow>
|
||||
@@ -350,8 +368,19 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
@@ -436,10 +465,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
<TableCell>
|
||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
|
||||
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
|
||||
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell>
|
||||
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} />
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -65,6 +67,7 @@ export default function NewSalesSlipPage() {
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,6 +107,8 @@ export default function NewSalesSlipPage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
// Narrow the UOM picker to units this item has a conversion for.
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
@@ -111,6 +116,11 @@ export default function NewSalesSlipPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
@@ -162,7 +172,7 @@ export default function NewSalesSlipPage() {
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
@@ -329,7 +339,7 @@ export default function NewSalesSlipPage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -346,6 +356,12 @@ export default function NewSalesSlipPage() {
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
{/* Makes the conversion visible while editing instead of at post time. */}
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
@@ -366,6 +382,11 @@ export default function NewSalesSlipPage() {
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -1,135 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Building2, Save } from "lucide-react"
|
||||
import { Building2 } from "lucide-react"
|
||||
|
||||
import { companyApi } from "@/lib/api/company"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CompanyProfile } from "@/types/company"
|
||||
|
||||
import { buttonVariants, Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function CompanyProfilePage() {
|
||||
const [profile, setProfile] = useState<CompanyProfile | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
companyApi
|
||||
.getProfile()
|
||||
.then((res) => {
|
||||
setProfile(res.data)
|
||||
setEtag(res.etag)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function patch<K extends keyof CompanyProfile>(key: K, value: CompanyProfile[K]) {
|
||||
setProfile((prev) => (prev ? { ...prev, [key]: value } : prev))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!profile || !etag) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await companyApi.updateProfile(profile, etag)
|
||||
setProfile(updated.data)
|
||||
setEtag(updated.etag)
|
||||
toast.success("Company profile saved", updated.data.legalName)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
|
||||
<Building2 className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
|
||||
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
|
||||
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to Settings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && !profile && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{!error && profile && (
|
||||
<div className="flex flex-col gap-6 rounded-2xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Invoice Header</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Legal Name" value={profile.legalName} onChange={(v) => patch("legalName", v)} />
|
||||
<Field label="Trade Name" value={profile.tradeName ?? ""} onChange={(v) => patch("tradeName", v)} />
|
||||
<Field label="Logo URL" value={profile.logoUrl ?? ""} onChange={(v) => patch("logoUrl", v)} />
|
||||
<Field label="Tax Registration No" value={profile.taxRegistrationNo ?? ""} onChange={(v) => patch("taxRegistrationNo", v)} />
|
||||
<Field label="VAT Registration No" value={profile.vatRegistrationNo ?? ""} onChange={(v) => patch("vatRegistrationNo", v)} />
|
||||
<Field label="Phone" value={profile.phone ?? ""} onChange={(v) => patch("phone", v)} />
|
||||
<Field label="Email" value={profile.email ?? ""} onChange={(v) => patch("email", v)} />
|
||||
<Field label="City" value={profile.city ?? ""} onChange={(v) => patch("city", v)} />
|
||||
<Field label="Country" value={profile.country ?? ""} onChange={(v) => patch("country", v)} />
|
||||
<Field label="Address Line 1" value={profile.addressLine1 ?? ""} onChange={(v) => patch("addressLine1", v)} />
|
||||
<Field label="Address Line 2" value={profile.addressLine2 ?? ""} onChange={(v) => patch("addressLine2", v)} />
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Save className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Bank Details</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Bank Name" value={profile.bankName ?? ""} onChange={(v) => patch("bankName", v)} />
|
||||
<Field label="Bank Branch" value={profile.bankBranch ?? ""} onChange={(v) => patch("bankBranch", v)} />
|
||||
<Field label="Account Name" value={profile.accountName ?? ""} onChange={(v) => patch("accountName", v)} />
|
||||
<Field label="Account Number" value={profile.accountNumber ?? ""} onChange={(v) => patch("accountNumber", v)} />
|
||||
<Field label="SWIFT Code" value={profile.swiftCode ?? ""} onChange={(v) => patch("swiftCode", v)} />
|
||||
<Field label="Footer Note" value={profile.footerNote ?? ""} onChange={(v) => patch("footerNote", v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PackageSearch, Search } from "lucide-react"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { formatQtyValue, formatQtyWithName } from "@/lib/uom"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { OnHand } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
@@ -135,11 +136,12 @@ export default function StockEnquiryPage() {
|
||||
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
|
||||
{/* Every figure here is base UOM; the label is what tells the user which. */}
|
||||
<TableCell className="px-3 py-3.5">{formatQtyWithName(row.onHand, row.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(row.available, row.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatQtyValue(row.onHold)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatQtyValue(row.inTransit)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatQtyValue(row.reserved)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { formatQtyWithName } from "@/lib/uom"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
@@ -189,10 +190,10 @@ export default function StockLedgerPage() {
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatQtyWithName(entry.qtyBase, entry.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(entry.runningBalance, entry.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,69 +1,3 @@
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
BadgeDollarSign,
|
||||
ClipboardList,
|
||||
PackageSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
title: "Stock Enquiry",
|
||||
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
|
||||
href: "/dashboard/stock/enquiry",
|
||||
icon: PackageSearch,
|
||||
},
|
||||
{
|
||||
title: "Stock Ledger",
|
||||
description: "The immutable, append-only movement journal — every in/out with running balance.",
|
||||
href: "/dashboard/stock/ledger",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
title: "Valuation",
|
||||
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
|
||||
href: "/dashboard/stock/valuation",
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
{
|
||||
title: "Transfers",
|
||||
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
|
||||
href: "/dashboard/stock/transfers",
|
||||
icon: ArrowLeftRight,
|
||||
},
|
||||
{
|
||||
title: "Adjustments",
|
||||
description: "Increase, decrease, or write off stock with a mandatory reason code.",
|
||||
href: "/dashboard/stock/adjustments",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
title: "Counts",
|
||||
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
|
||||
href: "/dashboard/stock/counts",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: "Reorder Alerts",
|
||||
description: "Items at or below their reorder point, with a one-click requisition.",
|
||||
href: "/dashboard/stock/reorder-alerts",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: "Wastage",
|
||||
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
|
||||
href: "/dashboard/stock/wastage",
|
||||
icon: AlertOctagon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function StockHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -73,26 +7,6 @@ export default function StockHubPage() {
|
||||
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AlertTriangle, CheckCircle2 } from "lucide-react"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatQty } from "@/lib/uom"
|
||||
import { ReorderAlert } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -19,16 +21,23 @@ export default function ReorderAlertsPage() {
|
||||
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [requesting, setRequesting] = useState<string | null>(null)
|
||||
const [requested, setRequested] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([a, it, wh]) => {
|
||||
Promise.all([
|
||||
stockApi.reorderAlerts(),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list(),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([a, it, wh, uo]) => {
|
||||
setAlerts(a.items)
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
setUoms(uo.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
@@ -44,7 +53,10 @@ export default function ReorderAlertsPage() {
|
||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||
setRequested((prev) => new Set(prev).add(key))
|
||||
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
|
||||
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
|
||||
// Requisitions are base-UOM only, so name the item's actual base unit rather than
|
||||
// the placeholder word "units".
|
||||
const uom = itemsById.get(alert.itemId)?.baseUomId
|
||||
toast.success("Requisition created", `${res.docNo} for ${formatQty(qty, uom ?? null, uoms)}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not create requisition", errorMessage(err))
|
||||
} finally {
|
||||
|
||||
+21
-13
@@ -5,8 +5,9 @@ import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
@@ -44,7 +45,6 @@ export default function VendorsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
@@ -52,8 +52,14 @@ export default function VendorsPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Separate from the paginated table list above — this needs every existing code (up to the
|
||||
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
|
||||
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
|
||||
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -75,8 +81,11 @@ export default function VendorsPage() {
|
||||
|
||||
useEffect(load, [query, status, page])
|
||||
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setCode("")
|
||||
setName("")
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
@@ -86,7 +95,6 @@ export default function VendorsPage() {
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
setErrors(nextErrors)
|
||||
@@ -94,14 +102,15 @@ export default function VendorsPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
setAllVendorCodes((codes) => [...codes, result.data.code])
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about codes loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -145,19 +154,18 @@ export default function VendorsPage() {
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record.</DialogDescription>
|
||||
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="v-code">Code</FieldLabel>
|
||||
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
||||
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { bundleApi } from "@/lib/api/bundles"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { BundleSaleSummary } from "@/types/bundles"
|
||||
|
||||
@@ -22,7 +23,7 @@ function statusClass(status: BundleSaleSummary["status"]) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function BundleBatchPrintPage() {
|
||||
function BundleBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
@@ -88,3 +89,11 @@ export default function BundleBatchPrintPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BundleBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<BundleBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</TableCell>
|
||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
@@ -22,7 +23,7 @@ function statusClass(status: SalesInvoiceSummary["status"]) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesInvoiceBatchPrintPage() {
|
||||
function SalesInvoiceBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
@@ -105,3 +106,11 @@ export default function SalesInvoiceBatchPrintPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SalesInvoiceBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<SalesInvoiceBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</TableCell>
|
||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SalesSlipSummary } from "@/types/sales"
|
||||
|
||||
@@ -22,7 +23,7 @@ function statusClass(status: SalesSlipSummary["status"]) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesSlipBatchPrintPage() {
|
||||
function SalesSlipBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
@@ -103,3 +104,11 @@ export default function SalesSlipBatchPrintPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SalesSlipBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<SalesSlipBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
Banknote,
|
||||
BadgeDollarSign,
|
||||
BookOpen,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageSearch,
|
||||
PackageX,
|
||||
PlayCircle,
|
||||
PieChart,
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
ReceiptText,
|
||||
Ruler,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
@@ -116,7 +121,26 @@ const navItems: {
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
href: "/dashboard/stock",
|
||||
// Clicking "Stock" itself lands on Stock Ledger — the hub page underneath has nothing on
|
||||
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||
landingHref: "/dashboard/stock/ledger",
|
||||
icon: Warehouse,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
||||
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||
{ title: "Counts", code: "stock.counts", href: "/dashboard/stock/counts", icon: ClipboardList },
|
||||
{ title: "Reorder Alerts", code: "stock.reorder-alerts", href: "/dashboard/stock/reorder-alerts", icon: AlertTriangle },
|
||||
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||
],
|
||||
},
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
@@ -402,14 +426,15 @@ export function AppSidebar() {
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
|
||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
|
||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
||||
@@ -165,9 +165,12 @@ export function Header() {
|
||||
const [notifications, setNotifications] = useState(initialNotifications)
|
||||
const unreadCount = notifications.filter((n) => n.unread).length
|
||||
|
||||
// Read after mount, not during render: localStorage doesn't exist on the server, and
|
||||
// reading it while rendering would desync the hydration pass.
|
||||
const [user] = useState<AuthUser | null>(() => getStoredUser())
|
||||
// Read after mount so the first client render matches the server render.
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setUser(getStoredUser())
|
||||
}, [])
|
||||
|
||||
const markAllAsRead = () =>
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||
@@ -278,16 +281,18 @@ export function Header() {
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||
{initials(displayName(user))}
|
||||
{user ? initials(displayName(user)) : "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
||||
{displayName(user)}
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||
<div className="px-2 py-2.5">
|
||||
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</p>
|
||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Plus } from "lucide-react"
|
||||
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -27,7 +27,7 @@ interface CodeNamed {
|
||||
|
||||
interface Api<T extends CodeNamed> {
|
||||
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||
create(request: { code: string; name: string }): Promise<{ value: T }>
|
||||
create(request: { code: string; name: string }): Promise<ApiResult<T>>
|
||||
updateStatus(id: number, status: EntityStatus): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Lightbulb, PackageCheck } from "lucide-react"
|
||||
@@ -11,7 +11,7 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
if (!suggestion || suggestion.lines.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||
No free-issue promotion suggestions were generated for this slip yet.
|
||||
No free-issue suggestions were generated for this slip yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,10 +22,10 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||
<Lightbulb className="size-3.5" />
|
||||
Backend suggestion
|
||||
Suggestion only
|
||||
</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
|
||||
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
|
||||
<p className="text-sm text-muted-foreground">These are suggestions only. You can review them before creating a free issue.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
Create free issue
|
||||
@@ -40,14 +40,12 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
<div className="font-medium text-foreground">{line.itemName}</div>
|
||||
<div className="text-sm text-muted-foreground">{line.itemSku} • Qty {line.qty}</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
Buy {line.triggerQty} get {line.suggestedFreeQty} free
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Free issue</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-sm">
|
||||
{line.rewardOptions.map((option, index) => (
|
||||
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
|
||||
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary")}>
|
||||
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
|
||||
{option.name}
|
||||
</span>
|
||||
@@ -55,11 +53,11 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||
Free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
// Per-item allowed-UOM lookup for document line forms.
|
||||
//
|
||||
// Every line on a document can reference a different item, so this caches by itemId and
|
||||
// fetches each one once. A naive refetch on every line edit would cost one request per line
|
||||
// on a 20-line invoice; the cache keeps it to one request per distinct item.
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { AllowedUom } from "@/types/master-data"
|
||||
|
||||
export interface AllowedUomLookup {
|
||||
/** Allowed units for an item, or `undefined` while it is still loading / unknown. */
|
||||
get: (itemId: number | null | undefined) => AllowedUom[] | undefined
|
||||
/** Ensure an item's units are loaded. Safe to call repeatedly; in-flight requests are shared. */
|
||||
load: (itemId: number | null | undefined) => void
|
||||
}
|
||||
|
||||
export function useAllowedUoms(): AllowedUomLookup {
|
||||
const [cache, setCache] = useState<Record<number, AllowedUom[]>>({})
|
||||
// Tracks in-flight and failed ids so a repeated render never re-issues the same request.
|
||||
const pending = useRef<Set<number>>(new Set())
|
||||
|
||||
const load = useCallback((itemId: number | null | undefined) => {
|
||||
if (!itemId || pending.current.has(itemId)) return
|
||||
pending.current.add(itemId)
|
||||
itemsApi
|
||||
.allowedUoms(itemId)
|
||||
.then((uoms) => setCache((prev) => ({ ...prev, [itemId]: uoms })))
|
||||
.catch(() => {
|
||||
// Leave the id marked so we don't hammer a failing endpoint. The picker falls back
|
||||
// to the global UOM list (see `pickerOptions`), and the server still rejects an
|
||||
// invalid unit on save — this is a degraded experience, not a correctness hole.
|
||||
})
|
||||
}, [])
|
||||
|
||||
const get = useCallback(
|
||||
(itemId: number | null | undefined) => (itemId ? cache[itemId] : undefined),
|
||||
[cache],
|
||||
)
|
||||
|
||||
return { get, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-item variant for screens with one item in scope (the conversion editor, a stock
|
||||
* enquiry filtered to one item).
|
||||
*/
|
||||
export function useItemAllowedUoms(itemId: number | null | undefined): AllowedUom[] {
|
||||
const [uoms, setUoms] = useState<AllowedUom[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemId) {
|
||||
setUoms([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
itemsApi
|
||||
.allowedUoms(itemId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setUoms(res)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUoms([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
return uoms
|
||||
}
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
ReceivedFromType,
|
||||
CreateReceivedChequeRequest,
|
||||
UpdateReceivedChequeStatusRequest,
|
||||
CHEQUE_BOOK_STATUS_BY_CODE,
|
||||
CHEQUE_PAGE_ISSUE_STATUS_BY_CODE,
|
||||
PAYEE_TYPE_BY_CODE,
|
||||
RECEIVED_FROM_TYPE_BY_CODE,
|
||||
RECEIVED_CHEQUE_STATUS_BY_CODE,
|
||||
} from "@/types/general-ledger"
|
||||
|
||||
const GL_BASE = "/api/v1/gl"
|
||||
@@ -257,50 +262,100 @@ export const cashAccountTypesApi = {
|
||||
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
|
||||
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
|
||||
*/
|
||||
// GL sends `ChequeBook.status`/`ChequePage.issueStatus`/`ChequePage.payeeType`/
|
||||
// `ReceivedCheque.receivedFromType`/`ReceivedCheque.status` as raw integers, not their string
|
||||
// name (06_Enums_Reference.md — no global JsonStringEnumConverter on GL's side; see the long
|
||||
// comment above the `*_BY_CODE` maps in types/general-ledger.ts for why). These `Raw*` shapes
|
||||
// describe exactly what GL's JSON actually contains for those fields; the `map*` functions below
|
||||
// translate them into this frontend's normal string-enum `ChequeBook`/`ChequePage`/`ReceivedCheque`
|
||||
// types immediately after each call returns, so every other file in this app can keep comparing
|
||||
// against `ChequeBookStatus.Active` etc. exactly as before.
|
||||
type RawChequePage = Omit<ChequePage, "issueStatus" | "payeeType"> & {
|
||||
issueStatus: number
|
||||
payeeType: number | null
|
||||
}
|
||||
type RawChequeBook = Omit<ChequeBook, "status" | "pages"> & {
|
||||
status: number
|
||||
pages: RawChequePage[]
|
||||
}
|
||||
type RawReceivedCheque = Omit<ReceivedCheque, "receivedFromType" | "status"> & {
|
||||
receivedFromType: number
|
||||
status: number
|
||||
}
|
||||
|
||||
function mapChequePage(raw: RawChequePage): ChequePage {
|
||||
return {
|
||||
...raw,
|
||||
issueStatus: CHEQUE_PAGE_ISSUE_STATUS_BY_CODE[raw.issueStatus],
|
||||
payeeType: raw.payeeType == null ? null : PAYEE_TYPE_BY_CODE[raw.payeeType],
|
||||
}
|
||||
}
|
||||
|
||||
function mapChequeBook(raw: RawChequeBook): ChequeBook {
|
||||
return {
|
||||
...raw,
|
||||
status: CHEQUE_BOOK_STATUS_BY_CODE[raw.status],
|
||||
pages: (raw.pages ?? []).map(mapChequePage),
|
||||
}
|
||||
}
|
||||
|
||||
function mapReceivedCheque(raw: RawReceivedCheque): ReceivedCheque {
|
||||
return {
|
||||
...raw,
|
||||
receivedFromType: RECEIVED_FROM_TYPE_BY_CODE[raw.receivedFromType],
|
||||
status: RECEIVED_CHEQUE_STATUS_BY_CODE[raw.status],
|
||||
}
|
||||
}
|
||||
|
||||
export const chequeBooksApi = {
|
||||
list(params?: {
|
||||
async list(params?: {
|
||||
bankAccountId?: number
|
||||
branchId?: number
|
||||
status?: ChequeBookStatus
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ChequeBook>> {
|
||||
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
|
||||
const res = await glRequest<GlPagedResult<RawChequeBook>>("/cheque-books", { query: { ...params } })
|
||||
return { ...res, items: res.items.map(mapChequeBook) }
|
||||
},
|
||||
|
||||
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
|
||||
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||
async get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||
const res = await glRequest<RawChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||
query: expandPages ? { expand: "pages" } : undefined,
|
||||
})
|
||||
return mapChequeBook(res)
|
||||
},
|
||||
|
||||
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
|
||||
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||
async create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||
const res = await glRequest<RawChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||
return mapChequeBook(res)
|
||||
},
|
||||
}
|
||||
|
||||
export const chequePagesApi = {
|
||||
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||
async issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
return mapChequePage(res)
|
||||
},
|
||||
|
||||
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
|
||||
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||
async updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
return mapChequePage(res)
|
||||
},
|
||||
}
|
||||
|
||||
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
|
||||
export const receivedChequesApi = {
|
||||
list(params?: {
|
||||
async list(params?: {
|
||||
companyId?: number
|
||||
branchId?: number
|
||||
status?: ReceivedChequeStatus
|
||||
@@ -308,15 +363,18 @@ export const receivedChequesApi = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ReceivedCheque>> {
|
||||
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||
const res = await glRequest<GlPagedResult<RawReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||
return { ...res, items: res.items.map(mapReceivedCheque) }
|
||||
},
|
||||
|
||||
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||
async create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||
const res = await glRequest<RawReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||
return mapReceivedCheque(res)
|
||||
},
|
||||
|
||||
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
|
||||
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||
async updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||
const res = await glRequest<RawReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||
return mapReceivedCheque(res)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
AllowedUom,
|
||||
CreateItemRequest,
|
||||
Item,
|
||||
ItemListItem,
|
||||
@@ -60,10 +61,20 @@ export const itemsApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the item's conversions. Every row must be `<other> → baseUomId` with a factor
|
||||
* greater than zero; the reverse direction, a self-conversion, or a row from the base UOM
|
||||
* is rejected with 422 (the engine only ever looks up `<other> → base` and never inverts).
|
||||
*/
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/** The UOMs this item can be transacted in — base UOM first, then each conversion source. */
|
||||
allowedUoms(itemId: number): Promise<AllowedUom[]> {
|
||||
return apiRequest<AllowedUom[]>(`/items/${itemId}/uoms`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// The single place the UI resolves and renders units of measure.
|
||||
//
|
||||
// Before this module, ~10 screens each re-declared their own inline `uoms.find(...)` under
|
||||
// three different names, with two different unknown-UOM fallbacks (`#12` vs `UOM 12`), and
|
||||
// there was no quantity formatter anywhere. Everything unit-shaped goes through here now.
|
||||
//
|
||||
// The one rule these helpers encode: **conversion toward base is authoritative.** The server
|
||||
// stores stock, layers and the ledger exclusively in an item's base UOM, and resolves the
|
||||
// base quantity when a document line is saved. `toBase` below exists to *preview* that for
|
||||
// the user at entry time; it is never the source of what gets posted.
|
||||
import { AllowedUom, Uom } from "@/types/master-data"
|
||||
|
||||
/** Shown when a uomId has no matching row — one fallback across the whole app. */
|
||||
const UNKNOWN_UOM = "—"
|
||||
|
||||
/** Quantities are `decimal(18,4)` server-side; trailing zeros are noise in a table. */
|
||||
const QTY_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
/** `12` -> "PCS". Accepts any list with `uomId`/`name`, so `Uom[]` and `AllowedUom[]` both work. */
|
||||
export function uomName(
|
||||
uomId: number | null | undefined,
|
||||
uoms: readonly { uomId: number; name: string }[],
|
||||
): string {
|
||||
if (uomId === null || uomId === undefined || uomId === 0) return UNKNOWN_UOM
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? UNKNOWN_UOM
|
||||
}
|
||||
|
||||
/** `1234.5` -> "1,234.5". Quantity-specific: unlike `formatAmount` it does not force 2dp. */
|
||||
export function formatQtyValue(qty: number | null | undefined): string {
|
||||
if (qty === null || qty === undefined || Number.isNaN(qty)) return "—"
|
||||
return QTY_FORMATTER.format(qty)
|
||||
}
|
||||
|
||||
/**
|
||||
* `(24, 12, uoms)` -> "24 PCS". The formatter every screen showing a quantity should use —
|
||||
* a bare number leaves the user guessing which unit a figure is in.
|
||||
*/
|
||||
export function formatQty(
|
||||
qty: number | null | undefined,
|
||||
uomId: number | null | undefined,
|
||||
uoms: readonly { uomId: number; name: string }[],
|
||||
): string {
|
||||
const value = formatQtyValue(qty)
|
||||
const unit = uomName(uomId, uoms)
|
||||
return unit === UNKNOWN_UOM ? value : `${value} ${unit}`
|
||||
}
|
||||
|
||||
/** Convenience for stock screens, whose DTOs carry `baseUomName` directly from the server. */
|
||||
export function formatQtyWithName(qty: number | null | undefined, uomName: string | null | undefined): string {
|
||||
const value = formatQtyValue(qty)
|
||||
return uomName ? `${value} ${uomName}` : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an entered quantity to the item's base UOM, matching the server's arithmetic
|
||||
* (multiply by the factor, round to 4dp). Display only — the authoritative base quantity is
|
||||
* the one the server resolves and snapshots when the line is saved.
|
||||
*/
|
||||
export function toBase(qty: number, factor: number): number {
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(factor)) return 0
|
||||
return Math.round(qty * factor * 10_000) / 10_000
|
||||
}
|
||||
|
||||
/**
|
||||
* The hint rendered beside a quantity input: `"= 24 PCS"` when the chosen unit is not the
|
||||
* item's base, and `null` when it is (a "= 24 PCS" next to "24 PCS" is just noise).
|
||||
*
|
||||
* Making the conversion visible at entry is the point — previously a user only discovered a
|
||||
* unit mismatch as a 422 when they tried to post the finished document.
|
||||
*/
|
||||
export function basePreview(
|
||||
qty: number | null | undefined,
|
||||
uomId: number | null | undefined,
|
||||
allowed: readonly AllowedUom[],
|
||||
): string | null {
|
||||
if (!qty || !uomId) return null
|
||||
const selected = allowed.find((u) => u.uomId === uomId)
|
||||
const base = allowed.find((u) => u.isBase)
|
||||
if (!selected || !base || selected.isBase) return null
|
||||
return `= ${formatQtyValue(toBase(qty, selected.factor))} ${base.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Falls back to the global UOM list while an item's allowed units are still loading (or when
|
||||
* no item is chosen yet), so a picker never renders empty. Once `allowed` arrives it wins —
|
||||
* that narrowing is the whole reason the endpoint exists.
|
||||
*/
|
||||
export function pickerOptions(allowed: readonly AllowedUom[] | undefined, all: readonly Uom[]): { uomId: number; name: string }[] {
|
||||
if (allowed && allowed.length > 0) return allowed.map((u) => ({ uomId: u.uomId, name: u.name }))
|
||||
return all.map((u) => ({ uomId: u.uomId, name: u.name }))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "VN"
|
||||
* so an empty/punctuation-only name still yields a usable base. Mirrors the warehouse
|
||||
* code generator (app/dashboard/warehouse/page.tsx). */
|
||||
function vendorCodeBase(name: string): string {
|
||||
const firstWord = name.trim().split(/\s+/)[0] ?? ""
|
||||
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||
return cleaned.slice(0, 10) || "VN"
|
||||
}
|
||||
|
||||
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
|
||||
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
|
||||
export function generateVendorCode(name: string, existingCodes: string[]): string {
|
||||
const base = `VN-${vendorCodeBase(name)}`
|
||||
if (!existingCodes.includes(base)) return base
|
||||
let suffix = 2
|
||||
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
@@ -410,6 +410,59 @@ export enum ReceivedChequeStatusAction {
|
||||
Cancel = "Cancel",
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed (`06_Enums_Reference.md`, user-supplied) — GL has **no global `JsonStringEnumConverter`**
|
||||
* registered. That's documented there as a request-body binding gotcha (a JSON-body enum field must
|
||||
* be sent as its string name, parsed server-side via `Enum.TryParse`), but the same missing converter
|
||||
* also governs the other direction: every one of these five fields is a real enum-typed property on
|
||||
* GL's own response DTO (backed by an `integer` DB column, per that doc's "Persisted enums" table),
|
||||
* so with no converter registered, GL's JSON response serializes each one as its **raw integer**
|
||||
* (`1`/`2`/`3`/...), not the name — confirmed live by the user ("most status has integer numbers").
|
||||
* Request bodies/query-string filters are unaffected and still take the string name as before (a
|
||||
* JSON-body enum field is independently declared `string` server-side, and query-string enum
|
||||
* binding parses names natively) — only inbound response values need translating. These maps do
|
||||
* that translation, keyed by the exact integer values `06_Enums_Reference.md` documents; applied in
|
||||
* `lib/api/general-ledger.ts` immediately after each GL call returns, so every consumer of
|
||||
* `ChequeBook`/`ChequePage`/`ReceivedCheque` in this frontend keeps working with the same string
|
||||
* enum values as before and never has to know GL sent a number.
|
||||
*/
|
||||
export const CHEQUE_BOOK_STATUS_BY_CODE: Record<number, ChequeBookStatus> = {
|
||||
1: ChequeBookStatus.Active,
|
||||
2: ChequeBookStatus.Completed,
|
||||
3: ChequeBookStatus.Cancelled,
|
||||
}
|
||||
|
||||
export const CHEQUE_PAGE_ISSUE_STATUS_BY_CODE: Record<number, ChequePageIssueStatus> = {
|
||||
1: ChequePageIssueStatus.Unused,
|
||||
2: ChequePageIssueStatus.Issued,
|
||||
3: ChequePageIssueStatus.Cleared,
|
||||
4: ChequePageIssueStatus.Bounced,
|
||||
5: ChequePageIssueStatus.Cancelled,
|
||||
6: ChequePageIssueStatus.Void,
|
||||
}
|
||||
|
||||
/** `ChequePage.payeeType` is nullable — only set once a page is issued (`06_Enums_Reference.md`). */
|
||||
export const PAYEE_TYPE_BY_CODE: Record<number, PayeeType> = {
|
||||
1: PayeeType.Supplier,
|
||||
2: PayeeType.Customer,
|
||||
3: PayeeType.Employee,
|
||||
4: PayeeType.Other,
|
||||
}
|
||||
|
||||
export const RECEIVED_FROM_TYPE_BY_CODE: Record<number, ReceivedFromType> = {
|
||||
1: ReceivedFromType.Customer,
|
||||
2: ReceivedFromType.Supplier,
|
||||
3: ReceivedFromType.Other,
|
||||
}
|
||||
|
||||
export const RECEIVED_CHEQUE_STATUS_BY_CODE: Record<number, ReceivedChequeStatus> = {
|
||||
1: ReceivedChequeStatus.Received,
|
||||
2: ReceivedChequeStatus.Deposited,
|
||||
3: ReceivedChequeStatus.Cleared,
|
||||
4: ReceivedChequeStatus.Returned,
|
||||
5: ReceivedChequeStatus.Cancelled,
|
||||
}
|
||||
|
||||
/**
|
||||
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
|
||||
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface ItemReorderSetting {
|
||||
reorderQty: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-item conversion row. Always directional: `fromUom → toUom`, where `toUom` is
|
||||
* required by the server to be the item's `baseUomId`. Quantity in `fromUom` × `factor`
|
||||
* = quantity in base UOM. Saving one in the opposite direction is rejected with 422.
|
||||
*/
|
||||
export interface UomConversion {
|
||||
conversionId: number
|
||||
fromUom: number
|
||||
@@ -40,6 +45,20 @@ export interface UomConversion {
|
||||
factor: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A UOM an item may actually be transacted in — from `GET /items/{itemId}/uoms`.
|
||||
*
|
||||
* Line forms must populate their UOM picker from this rather than the global `uoms` list:
|
||||
* choosing a unit the item has no conversion for is rejected when the line is saved.
|
||||
*/
|
||||
export interface AllowedUom {
|
||||
uomId: number
|
||||
name: string
|
||||
/** Multiply a quantity in this UOM by `factor` to get the item's base UOM. 1 for the base itself. */
|
||||
factor: number
|
||||
isBase: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
|
||||
*
|
||||
|
||||
@@ -138,11 +138,23 @@ export interface PoLine {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
/** Ordered quantity, in `uomId`. */
|
||||
qty: number
|
||||
unitPrice: number
|
||||
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
||||
tax: number
|
||||
/**
|
||||
* Received to date, in `uomId`. **Display only** — the server derives it by dividing
|
||||
* `qtyReceivedBase` by `conversionFactor`, so it can drift. Use the base pair below for
|
||||
* any remaining/outstanding/closed logic.
|
||||
*/
|
||||
qtyReceived: number
|
||||
/** Ordered quantity in the item's base UOM — what over-receipt and PO closure compare on. */
|
||||
qtyBase: number
|
||||
/** Received to date in the item's base UOM. Authoritative. */
|
||||
qtyReceivedBase: number
|
||||
/** Factor used to derive the base pair from `uomId`; 1 when the line is in base UOM. */
|
||||
conversionFactor: number
|
||||
}
|
||||
|
||||
export interface PoTotals {
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface OnHand {
|
||||
/** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */
|
||||
reserved: number
|
||||
asOf: string
|
||||
/** The item's base UOM — every quantity above is in it. For labelling, not conversion. */
|
||||
baseUomId: number
|
||||
baseUomName: string
|
||||
}
|
||||
|
||||
// --- 5.2 Ledger -------------------------------------------------------------------
|
||||
@@ -41,6 +44,9 @@ export interface LedgerEntry {
|
||||
sourceDocId: number
|
||||
userId: number
|
||||
createdAt: string
|
||||
/** The item's base UOM — `qtyBase` and `runningBalance` are in it. */
|
||||
baseUomId: number
|
||||
baseUomName: string
|
||||
}
|
||||
|
||||
// --- 5.3 Valuation ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copy to .env.e2e and fill in real values. Never commit .env.e2e.
|
||||
|
||||
# Frontend origin the browser navigates to (Next.js proxies /api/v1/* server-side from here).
|
||||
E2E_BASE_URL=http://localhost:3000
|
||||
|
||||
# Backend origin, used only to build absolute API paths in error messages / docs; all
|
||||
# actual requests go through E2E_BASE_URL's same-origin /api/v1 proxy.
|
||||
E2E_API_URL=http://localhost:5224
|
||||
|
||||
# Credentials for a real AuthHex-backed user with access to Receiving, Production, and
|
||||
# Stock modules. AuthHex is an external identity provider (see docs/11-BACKEND-PHASE1.md
|
||||
# §2.0) - there is no local seed for this account, it must already exist upstream.
|
||||
E2E_ADMIN_EMAIL=e2e-tester@example.com
|
||||
E2E_ADMIN_PASSWORD=change-me
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user