Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6d939059 | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
@@ -30,6 +30,12 @@ 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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -42,6 +42,18 @@ public sealed class GrnsController : ApiControllerBase
|
||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines.</summary>
|
||||
[HttpPut("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<GrnDto>> Update(int grnId, [FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.UpdateAsync(grnId, request, ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||
[HttpPost("{grnId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -69,6 +69,8 @@ public sealed class CreateGrnRequest
|
||||
public int? VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||
/// <summary>Optional document-level discount percentage (0–100). When supplied, per-line discounts are ignored.</summary>
|
||||
[Range(0, 100)] public decimal? TotalDiscountPct { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReleaseLineRequest
|
||||
|
||||
+7093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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,23 @@ 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);
|
||||
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, 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,
|
||||
Qty = qtyBase,
|
||||
UomId = item.BaseUomId,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = unitCostBase,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
|
||||
@@ -322,6 +322,110 @@ public sealed class GrnService : IGrnService
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||
}
|
||||
|
||||
public async Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status != GrnStatus.Draft)
|
||||
throw new ConflictException($"GRN {grnId} is {grn.Status} and can no longer be edited.");
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
PurchaseOrder? po = null;
|
||||
int vendorId;
|
||||
if (request.PoId is not null)
|
||||
{
|
||||
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||
vendorId = po.VendorId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.VendorId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
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);
|
||||
|
||||
decimal unitCost;
|
||||
decimal? poUnitPrice = null;
|
||||
if (input.PoLineId is not null)
|
||||
{
|
||||
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
|
||||
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))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
unitCost = input.UnitCost;
|
||||
}
|
||||
|
||||
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
|
||||
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch,
|
||||
Qty = input.Qty,
|
||||
UnitCost = unitCost,
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
NetUnitCost = netUnitCost,
|
||||
VatPct = input.VatPct,
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
});
|
||||
}
|
||||
|
||||
grn.PoId = request.PoId;
|
||||
grn.VendorId = vendorId;
|
||||
grn.WarehouseId = request.WarehouseId;
|
||||
grn.Lines.Clear();
|
||||
foreach (var line in lines) grn.Lines.Add(line);
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(grn);
|
||||
}
|
||||
|
||||
private async Task<Batch?> ResolveBatchAsync(
|
||||
Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,9 @@ public interface IGrnService
|
||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines; rejects if the GRN is no longer Draft.</summary>
|
||||
Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
||||
Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
@@ -30,6 +31,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -119,15 +122,14 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||
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 })
|
||||
.FirstAsync(ct);
|
||||
|
||||
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.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
@@ -139,10 +141,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.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
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 +154,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.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
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 +167,12 @@ 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)),
|
||||
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||
// stored base quantity directly instead of converting again.
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 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 +214,5 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=187.127.102.190;Port=5432;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": {}
|
||||
}
|
||||
@@ -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,8 +3,7 @@
|
||||
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"
|
||||
@@ -195,10 +194,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 +282,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" />
|
||||
|
||||
@@ -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"
|
||||
@@ -15,13 +15,22 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
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 +52,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" }
|
||||
}
|
||||
@@ -73,22 +82,83 @@ function NewPurchaseOrderContent() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
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(),
|
||||
uomsApi.list().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 +306,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 +386,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 +425,15 @@ 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}>
|
||||
<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>
|
||||
@@ -374,6 +490,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>
|
||||
|
||||
|
||||
+245
-245
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
@@ -15,6 +15,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"
|
||||
@@ -175,265 +176,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={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={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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,7 +188,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { ExternalLink, Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -44,6 +44,11 @@ function isBuilderItemType(name: string): boolean {
|
||||
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** Opens a master-data management page in a new tab so the in-progress form isn't lost. */
|
||||
function openInNewTab(path: string) {
|
||||
window.open(path, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
export default function NewItemPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -280,7 +285,20 @@ export default function NewItemPage() {
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/categories")}
|
||||
aria-label="Add category"
|
||||
title="Add category"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={handleCategoryChange}
|
||||
@@ -303,7 +321,22 @@ export default function NewItemPage() {
|
||||
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() =>
|
||||
openInNewTab(categoryId ? `/dashboard/products/categories/${categoryId}` : "/dashboard/products/categories")
|
||||
}
|
||||
aria-label="Add subcategory"
|
||||
title="Add subcategory"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={subCategoryId}
|
||||
onValueChange={setSubCategoryId}
|
||||
@@ -325,7 +358,20 @@ export default function NewItemPage() {
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/brands")}
|
||||
aria-label="Add brand"
|
||||
title="Add brand"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={brandId}
|
||||
onValueChange={setBrandId}
|
||||
@@ -345,7 +391,20 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/warehouse")}
|
||||
aria-label="Add warehouse"
|
||||
title="Add warehouse"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
@@ -364,7 +423,20 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/uoms")}
|
||||
aria-label="Add UOM"
|
||||
title="Add UOM"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={baseUomId}
|
||||
onValueChange={setBaseUomId}
|
||||
@@ -454,11 +526,22 @@ export default function NewItemPage() {
|
||||
item-type reference), so this section IS the enforcement. */}
|
||||
{config?.itemTypesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openInNewTab("/dashboard/products/item-types")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Manage item types
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -130,10 +130,15 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
|
||||
{grn.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/dashboard/receiving/grn/new?grnId=${grn.grnId}`} className={cn(buttonVariants({ size: "lg", variant: "outline" }))}>
|
||||
Edit
|
||||
</Link>
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -86,6 +86,8 @@ function emptyLine(): DraftLine {
|
||||
|
||||
export default function NewGrnPage() {
|
||||
const router = useRouter()
|
||||
const search = useSearchParams()
|
||||
const editingGrnId = Number(search?.get("grnId")) || null
|
||||
|
||||
const [mode, setMode] = useState<Mode>("po")
|
||||
|
||||
@@ -102,6 +104,7 @@ export default function NewGrnPage() {
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [totalDiscount, setTotalDiscount] = useState<string>("")
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
@@ -127,6 +130,37 @@ export default function NewGrnPage() {
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
// If editing an existing draft GRN, load and populate the form.
|
||||
useEffect(() => {
|
||||
if (!editingGrnId) return
|
||||
grnsApi
|
||||
.get(editingGrnId)
|
||||
.then((g) => {
|
||||
setPoId(g.poId ?? null)
|
||||
setVendorId(g.vendorId ?? null)
|
||||
setWarehouseId(g.warehouseId)
|
||||
setLines(
|
||||
g.lines.map((ln) => ({
|
||||
key: newKey(),
|
||||
poLineId: ln.poLineId,
|
||||
itemId: ln.itemId,
|
||||
uomId: ln.uomId,
|
||||
binId: ln.binId,
|
||||
qty: String(ln.qty),
|
||||
unitCost: String(ln.unitCost),
|
||||
poUnitPrice: ln.poUnitPrice,
|
||||
discountPct: String(ln.discountPct),
|
||||
vatPct: String(ln.vatPct),
|
||||
holdStatus: ln.holdStatus,
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [editingGrnId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouseId) {
|
||||
setBins([])
|
||||
@@ -208,6 +242,15 @@ export default function NewGrnPage() {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
// When a document-level total discount is entered, clear any per-line discounts.
|
||||
function updateTotalDiscount(next: string) {
|
||||
setTotalDiscount(next)
|
||||
const pct = Number(next) || 0
|
||||
if (pct > 0) {
|
||||
setLines((prev) => prev.map((l) => ({ ...l, discountPct: "0" })))
|
||||
}
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
@@ -272,7 +315,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
// Use document-level discount if supplied, otherwise per-line discount.
|
||||
discountPct: (Number(totalDiscount) || 0) > 0 ? (Number(totalDiscount) || 0) : Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
@@ -282,13 +326,26 @@ export default function NewGrnPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
let grn
|
||||
if (editingGrnId) {
|
||||
grn = await grnsApi.update(editingGrnId, {
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
totalDiscount: Number(totalDiscount) || undefined,
|
||||
})
|
||||
toast.success("GRN updated", `${grn.docNo} is ready to confirm.`)
|
||||
} else {
|
||||
grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
totalDiscount: Number(totalDiscount) || undefined,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
}
|
||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
@@ -440,225 +497,251 @@ 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) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-md max-w-[80vw]" align="start">
|
||||
{(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={(Number(totalDiscount) || 0) > 0 ? totalDiscount : line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
disabled={(Number(totalDiscount) || 0) > 0}
|
||||
/>
|
||||
<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">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 text-base">
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<label className="text-sm text-muted-foreground">Total discount %</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={totalDiscount}
|
||||
onChange={(e) => updateTotalDiscount(e.target.value)}
|
||||
className="w-28 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{(() => {
|
||||
const totalReceived = lines.reduce((s, l) => s + computeLine(l).receivedValue, 0)
|
||||
const totalVat = lines.reduce((s, l) => s + computeLine(l).vatAmount, 0)
|
||||
const pct = Number(totalDiscount) || 0
|
||||
if (pct > 0) {
|
||||
const discountedBase = totalReceived * (1 - pct / 100)
|
||||
const discountedVat = lines.reduce((s, l) => s + computeLine(l).vatAmount * (1 - pct / 100), 0)
|
||||
return (discountedBase + discountedVat).toFixed(2)
|
||||
}
|
||||
return (totalReceived + totalVat).toFixed(2)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -671,7 +754,7 @@ export default function NewGrnPage() {
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
{submitting ? (editingGrnId ? "Saving…" : "Creating…") : editingGrnId ? "Save Changes" : "Create GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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"
|
||||
@@ -26,6 +26,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":
|
||||
@@ -94,7 +106,7 @@ export default function BundleSaleDetailPage() {
|
||||
key: `${line.bundleSaleLineId}`,
|
||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -104,7 +116,7 @@ export default function BundleSaleDetailPage() {
|
||||
)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [bundleSaleId, params.id])
|
||||
}, [bundleSaleId, params.id, items])
|
||||
|
||||
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 +128,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 +182,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 +262,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 +371,7 @@ 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 })
|
||||
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,7 +386,7 @@ 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}>
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
@@ -334,15 +401,78 @@ export default function BundleSaleDetailPage() {
|
||||
</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-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>
|
||||
</section>
|
||||
|
||||
{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-3 md:grid-cols-3">
|
||||
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></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"
|
||||
@@ -16,6 +16,7 @@ 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 +27,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")
|
||||
@@ -80,10 +91,20 @@ 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) => {
|
||||
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
||||
return createBlankLine({
|
||||
...line,
|
||||
uomId: item?.baseUomId ?? 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 +114,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 +169,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 +256,14 @@ 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)
|
||||
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,7 +278,7 @@ 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) })}>
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
@@ -257,12 +293,30 @@ export default function NewBundleSalePage() {
|
||||
</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-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 +340,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
|
||||
|
||||
@@ -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"
|
||||
@@ -157,6 +157,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 +186,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 +314,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>
|
||||
@@ -379,8 +383,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 +448,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"}
|
||||
@@ -497,15 +521,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
/>
|
||||
</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 +539,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
|
||||
|
||||
@@ -400,6 +400,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,7 +2,7 @@
|
||||
|
||||
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"
|
||||
@@ -155,6 +155,11 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
})
|
||||
}
|
||||
|
||||
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 +177,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 +340,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 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 +360,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">
|
||||
|
||||
@@ -111,6 +111,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 +167,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),
|
||||
@@ -366,6 +371,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+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" />
|
||||
|
||||
@@ -129,47 +129,45 @@
|
||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
||||
}
|
||||
|
||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
||||
cards, header, table panels) paired with a dark sidebar — the same split
|
||||
Linear/Vercel/Notion use in their light themes. One violet accent drives
|
||||
every interactive state; the sidebar keeps its own dark token family
|
||||
(applied via .sidebar-surface below) so it stays dark regardless. */
|
||||
/* Vibrant — the "System" toggle option. Light gray background and sidebar
|
||||
throughout (no light content split), with one violet accent driving every
|
||||
interactive state. */
|
||||
.vibrant {
|
||||
--background: oklch(0.97 0.004 265);
|
||||
--foreground: oklch(0.2 0.02 265);
|
||||
--card: oklch(0.995 0.002 265);
|
||||
--card-foreground: oklch(0.2 0.02 265);
|
||||
--popover: oklch(0.995 0.002 265);
|
||||
--popover-foreground: oklch(0.2 0.02 265);
|
||||
--primary: oklch(0.55 0.2 275);
|
||||
--background: oklch(0.82 0 0);
|
||||
--foreground: oklch(0.2 0 0);
|
||||
--card: oklch(0.99 0 0);
|
||||
--card-foreground: oklch(0.2 0 0);
|
||||
--popover: oklch(0.99 0 0);
|
||||
--popover-foreground: oklch(0.2 0 0);
|
||||
--primary: oklch(0.5 0.2 275);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.93 0.02 275);
|
||||
--secondary-foreground: oklch(0.32 0.15 275);
|
||||
--muted: oklch(0.94 0.006 265);
|
||||
--muted-foreground: oklch(0.48 0.02 265);
|
||||
--accent: oklch(0.55 0.14 210);
|
||||
--secondary: oklch(0.82 0.02 275);
|
||||
--secondary-foreground: oklch(0.35 0.15 275);
|
||||
--muted: oklch(0.85 0 0);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
--accent: oklch(0.55 0.13 210);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.58 0.22 25);
|
||||
--border: oklch(0.88 0.012 265);
|
||||
--input: oklch(0.92 0.01 265);
|
||||
--ring: oklch(0.55 0.2 275);
|
||||
--success: oklch(0.55 0.15 150);
|
||||
--warning: oklch(0.72 0.15 80);
|
||||
--error: oklch(0.58 0.22 25);
|
||||
--info: oklch(0.55 0.14 210);
|
||||
--chart-1: oklch(0.55 0.2 275);
|
||||
--chart-2: oklch(0.55 0.14 210);
|
||||
--chart-3: oklch(0.55 0.15 150);
|
||||
--chart-4: oklch(0.72 0.15 80);
|
||||
--chart-5: oklch(0.58 0.22 25);
|
||||
--sidebar: oklch(0.18 0.02 265);
|
||||
--sidebar-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-primary: oklch(0.64 0.19 275);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--border: oklch(0.78 0 0);
|
||||
--input: oklch(0.8 0 0);
|
||||
--ring: oklch(0.5 0.2 275);
|
||||
--success: oklch(0.5 0.15 142.495);
|
||||
--warning: oklch(0.65 0.15 72.031);
|
||||
--error: oklch(0.55 0.22 25);
|
||||
--info: oklch(0.55 0.13 210);
|
||||
--chart-1: oklch(0.5 0.2 275);
|
||||
--chart-2: oklch(0.55 0.13 210);
|
||||
--chart-3: oklch(0.5 0.15 142.495);
|
||||
--chart-4: oklch(0.65 0.15 72.031);
|
||||
--chart-5: oklch(0.55 0.22 25);
|
||||
--sidebar: oklch(0.82 0 0);
|
||||
--sidebar-foreground: oklch(0.2 0 0);
|
||||
--sidebar-primary: oklch(0.5 0.19 275);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.28 0.03 265);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-border: oklch(0.26 0.025 265);
|
||||
--sidebar-ring: oklch(0.64 0.19 275);
|
||||
--sidebar-accent: oklch(0.75 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.2 0 0);
|
||||
--sidebar-border: oklch(0.7 0 0);
|
||||
--sidebar-ring: oklch(0.5 0.19 275);
|
||||
}
|
||||
|
||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||
@@ -186,7 +184,7 @@
|
||||
--popover-foreground: var(--sidebar-foreground);
|
||||
--foreground: var(--sidebar-foreground);
|
||||
--muted: var(--sidebar-accent);
|
||||
--muted-foreground: oklch(0.72 0.015 265);
|
||||
--muted-foreground: oklch(0.4 0.015 265);
|
||||
--primary: var(--sidebar-primary);
|
||||
--primary-foreground: var(--sidebar-primary-foreground);
|
||||
--border: var(--sidebar-border);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -115,8 +120,27 @@ const navItems: {
|
||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
|
||||
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
|
||||
@@ -61,6 +61,50 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Flattens a node's rendered text so a SelectItem can be matched against a search query. */
|
||||
function nodeToText(node: React.ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") return ""
|
||||
if (typeof node === "string" || typeof node === "number") return String(node)
|
||||
if (Array.isArray(node)) return node.map(nodeToText).join(" ")
|
||||
if (React.isValidElement(node)) {
|
||||
return nodeToText((node.props as { children?: React.ReactNode }).children)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Walks the popup's children, dropping any SelectItem whose text doesn't match the query. */
|
||||
function filterSelectChildren(children: React.ReactNode, query: string): React.ReactNode {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return children
|
||||
|
||||
return React.Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) return child
|
||||
if (child.type === SelectItem) {
|
||||
const text = nodeToText((child.props as { children?: React.ReactNode }).children).toLowerCase()
|
||||
return text.includes(q) ? child : null
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode }).children
|
||||
if (nested !== undefined) {
|
||||
return React.cloneElement(child, undefined, filterSelectChildren(nested, query))
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
function countSelectItems(node: React.ReactNode): number {
|
||||
let count = 0
|
||||
React.Children.forEach(node, (child) => {
|
||||
if (!React.isValidElement(child)) return
|
||||
if (child.type === SelectItem) {
|
||||
count += 1
|
||||
return
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode }).children
|
||||
if (nested !== undefined) count += countSelectItems(nested)
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
@@ -96,13 +140,27 @@ function SelectContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
// Aligning the popup to the selected item lets it float above the trigger (and above the
|
||||
// search box). A plain dropdown that always opens fully below the trigger is what the
|
||||
// search box needs to stay pinned to the top, so this defaults to off now.
|
||||
alignItemWithTrigger = false,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const searchRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// The popup remounts each time it opens, so this only ever fires once per open.
|
||||
React.useEffect(() => {
|
||||
searchRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const filteredChildren = React.useMemo(() => filterSelectChildren(children, query), [children, query])
|
||||
const noResults = query.trim().length > 0 && countSelectItems(filteredChildren) === 0
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
@@ -119,8 +177,27 @@ function SelectContent({
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<div data-slot="select-search" className="sticky top-0 z-10 bg-popover p-1.5 pb-1">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation()
|
||||
}}
|
||||
placeholder="Search…"
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent pr-2 pl-7 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectPrimitive.List>{filteredChildren}</SelectPrimitive.List>
|
||||
{noResults && (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">No results found.</div>
|
||||
)}
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ export const grnsApi = {
|
||||
return apiRequest<Grn>("/grns", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
|
||||
return apiRequest<Grn>(`/grns/${grnId}`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
/**
|
||||
* Posts the receipt. Pass a stable idempotencyKey per detail-page session so a retry
|
||||
* cannot double-post stock — unlike the old mock, the server genuinely dedupes on it.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface CreateGrnRequest {
|
||||
vendorId?: number | null
|
||||
warehouseId: number
|
||||
lines: CreateGrnLineInput[]
|
||||
/** Optional total document-level discount % (0–100). When set, per-line discounts are cleared. */
|
||||
totalDiscount?: number
|
||||
}
|
||||
|
||||
export interface GrnLine {
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,155 @@
|
||||
# ERP-Core E2E tests (Playwright)
|
||||
|
||||
End-to-end tests for the three phases requested first: **GRN (receiving)**, **Production
|
||||
runs**, and **Stock movement** (transfers + adjustments), plus one chained scenario that
|
||||
walks all three in sequence. Sales and Accounts are intentionally out of scope for now.
|
||||
|
||||
## Why Playwright, not Selenium
|
||||
|
||||
The frontend is Next.js 16 / React 19. Playwright auto-waits for React state updates,
|
||||
ships trace/video capture on failure, and can drive the backend API directly (used here to
|
||||
seed test data), which made it a better fit than Selenium for this stack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Backend running locally: `cd Backend/ERPCore && dotnet ef database update && dotnet run`
|
||||
(needs `ASPNETCORE_ENVIRONMENT=Development` set — see the repo's local-env notes — and a
|
||||
reachable Postgres instance). Defaults to `http://localhost:5224`.
|
||||
2. Frontend running locally: `cd Frontend/erp-system && npm install && npm run dev`.
|
||||
Defaults to `http://localhost:3000` and proxies `/api/v1/*` to the backend same-origin.
|
||||
3. A real login for the tests. **Auth is fronted by an external AuthHex identity provider**
|
||||
(`Backend/ERPCore/Controllers/AuthController.cs`) — there is no local seed for a user
|
||||
account, so `E2E_ADMIN_EMAIL`/`E2E_ADMIN_PASSWORD` must be a real, already-provisioned
|
||||
account with access to Receiving, Production, and Stock.
|
||||
4. A fresh-ish database is fine: `DataSeeder` (`Backend/ERPCore/Infra/Persistence/DataSeeder.cs`)
|
||||
seeds the `MAIN`/`SHOP` warehouses, `PCS`/`BOX` UOMs, and a `General Goods` category that
|
||||
these tests rely on existing. Everything else (vendors, items, purchase orders, a
|
||||
production template) is created fresh per run by `support/api.ts` with unique
|
||||
timestamp-suffixed codes, so reruns never collide with previous data.
|
||||
5. **`E2E_BASE_URL` must use `http://localhost`, not `127.0.0.1` or a LAN IP.** The session
|
||||
cookie is written with `Secure = true` unconditionally
|
||||
(`Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs`); Chromium only treats plain-HTTP
|
||||
`localhost` as a secure-enough origin to accept and resend a `Secure` cookie, so anything
|
||||
else silently drops the session and every post-login request 401s.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd Testing/e2e
|
||||
npm install
|
||||
npx playwright install --with-deps chromium
|
||||
cp .env.e2e.example .env.e2e # then fill in E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npm run test:e2e # headless, all specs
|
||||
npm run test:e2e:ui # interactive UI mode — best for first-run locator debugging
|
||||
npm run test:e2e:headed # headed browser
|
||||
npm run report # open the last HTML report
|
||||
```
|
||||
|
||||
The `setup` project (`specs/global.setup.ts`) logs in once through the real `/login` form
|
||||
— the session is an httpOnly cookie, so there's no token to inject — and saves it to
|
||||
`.auth/admin.json`. Every other spec's `chromium` project reuses that storage state, so
|
||||
individual specs don't re-authenticate. `auth.spec.ts` is the exception: it explicitly runs
|
||||
with no stored session so it can exercise the login form itself.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Testing/e2e/
|
||||
├── playwright.config.ts
|
||||
├── support/
|
||||
│ ├── env.ts # reads .env.e2e, resolves the storageState path
|
||||
│ └── api.ts # ApiSeeder — creates vendors/items/POs/templates, reads stock on-hand
|
||||
├── pages/ # Page Object Models (one file per module)
|
||||
└── specs/
|
||||
├── global.setup.ts
|
||||
├── auth.spec.ts
|
||||
├── grn.spec.ts
|
||||
├── production.spec.ts
|
||||
├── stock-transfers.spec.ts
|
||||
├── stock-adjustments.spec.ts
|
||||
└── chained-flow.spec.ts # GRN -> Production -> Stock Transfer, one continuous scenario
|
||||
```
|
||||
|
||||
## Coverage vs. what's deferred
|
||||
|
||||
18 tests across auth, GRN, production runs, stock transfers/adjustments, and one chained
|
||||
flow. Deliberately deferred (all would need a second, multi-stage production template or
|
||||
custom-field scaffolding to exercise, which felt like scope creep for a first pass):
|
||||
|
||||
- **Approve & transfer** on a non-terminal stage, and transferring a held-back remainder
|
||||
from an `Approved` stage — both only apply to a multi-stage graph; the seeded template is
|
||||
single-stage (entry == terminal) so every run here only ever exercises "Approve & receive".
|
||||
- **Reject intake** (pulling back delivered upstream WIP) — same reason, needs a
|
||||
parent→child edge.
|
||||
- Client-side validation edges inside `StageDrawer`: completing a stage with produced qty
|
||||
over the staged input, or a scrap qty with no scrap reason selected.
|
||||
- Adjustment reason-code → ledger-entry tagging spot-check (`GET /stock/ledger`) — the
|
||||
positive/negative adjustment tests verify on-hand moves correctly but don't inspect the
|
||||
ledger rows themselves.
|
||||
|
||||
**Long-run item-dropdown ceiling.** The GRN/Transfer/Adjustment "new" pages load items via
|
||||
`itemsApi.list({ pageSize: 200 })` (a fixed page, not paginated further in the UI). Every
|
||||
spec run mints 2-3 new permanent items through `ApiSeeder.createItem`, and nothing deletes
|
||||
them. Once a dev database accumulates more than 200 active items, freshly-seeded items stop
|
||||
appearing in the Item combobox (and if the list sorts ascending by id, it's exactly the
|
||||
newest ones that fall off) — locators like `getByRole("option", { name: item.name })` will
|
||||
time out with no visible cause. If that starts happening, the fix is to seed one stable
|
||||
per-module item once and reuse it across runs instead of minting a fresh one each time
|
||||
(every assertion here is already delta-based, so that's a drop-in change).
|
||||
|
||||
## Known limitation: a real, reproducible hydration bug
|
||||
|
||||
Every load of the GRN/Production/Stock pages throws a genuine React hydration error
|
||||
("Minified React error #418" — text content mismatch between server and client render).
|
||||
It is **not intermittent** — it fires on every navigation — but its effect is: hydration
|
||||
recovery blanks the placeholder text of a random subset of that page's Select triggers for
|
||||
the rest of that page's life, while leaving the sibling `<Label>`/`<FieldLabel>` and the
|
||||
trigger's `role="combobox"` attribute intact. A `getByRole("combobox", { name: ... })`
|
||||
lookup is therefore unreliable on these pages; `support/ui.ts`'s `comboboxByLabel()` works
|
||||
around it by finding the trigger via its stable sibling label + role alone, never its
|
||||
(possibly-blanked) accessible name. The same file's `retryClick`/`clickToReveal`/
|
||||
`clickToRevealWithReload`/`submitAndWait` cover two related, separately-confirmed issues:
|
||||
short-lived disabled/not-yet-mounted trigger buttons (`RunActions.tsx`'s "Cancel run"/
|
||||
"Return leftover", gated on `run.status`), and stage/document actions whose UI only
|
||||
reflects an async POST once the response lands — reading stock through the API immediately
|
||||
after a click can otherwise race the backend commit. This is worth a look on the product
|
||||
side (root-causing the actual SSR/CSR mismatch would remove the workaround entirely), but
|
||||
was out of scope for a first E2E pass.
|
||||
|
||||
**Backend also can't take concurrent Playwright workers yet.** Reference-data GETs
|
||||
(`/warehouses`, etc.) intermittently 500 when 2+ workers hit a plain `dotnet run` +
|
||||
local Postgres backend at once — confirmed by re-running the exact same suite at
|
||||
`workers: 1` with zero failures. `playwright.config.ts` pins `workers: 1` for that reason;
|
||||
raise it only against a backend that can actually take concurrent load.
|
||||
|
||||
## Known limitation: no `data-testid`s yet
|
||||
|
||||
None of the GRN/Production/Stock Transfer/Stock Adjustment components in
|
||||
`Frontend/erp-system` currently expose `data-testid` attributes, and several form controls
|
||||
have no accessible name at all (the Qty/Unit cost/Disc%/VAT% `<Input type="number">` cells
|
||||
in the GRN and Transfer line tables aren't wrapped in a `<label>` or given `aria-label`).
|
||||
Locators in `pages/` work around this with role/placeholder matching where an accessible
|
||||
name exists, and row + column-position locators (`row.locator('input[type="number"]').nth(n)`)
|
||||
where it doesn't — every such case is called out in a comment at the top of the relevant
|
||||
`pages/*.ts` file, along with the couple of same-text button pairs (e.g. "Cancel run" is
|
||||
both the trigger and the dialog's confirm label) that needed `.first()`/`.last()` to
|
||||
disambiguate. If a component's copy or layout changes, run `npm run test:e2e:ui` to see
|
||||
exactly which locator broke and fix it in `pages/*.ts` — the specs themselves shouldn't need
|
||||
to change.
|
||||
|
||||
**Recommended fast-follow** (not done here, since it's a product-code change rather than a
|
||||
test-authoring one): add `data-testid` to the Select triggers, the Qty/cost inputs, and the
|
||||
line-table rows in the receiving/production/stock components. That would let every locator
|
||||
above swap from role/position matching to exact `data-testid` matching in one pass.
|
||||
|
||||
## CI
|
||||
|
||||
Not wired up yet — no GitHub Actions workflow exists in this repo. Once these specs are
|
||||
green locally, add `.github/workflows/e2e.yml` (spin up Postgres + backend + frontend as
|
||||
services, run `npm run test:e2e`, upload `playwright-report/` as an artifact) as a
|
||||
follow-up.
|
||||
Generated
+125
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"name": "erp-core-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erp-core-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "erp-core-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Playwright end-to-end tests for GRN, Production Runs, and Stock Movement flows.",
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Page, expect } from "@playwright/test"
|
||||
import { clickToReveal, selectOption, comboboxByLabel } from "../support/ui"
|
||||
|
||||
// Locators verified against Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx
|
||||
// and .../grn/[id]/page.tsx. Things that DOM inspection caught and a placeholder-only guess
|
||||
// would not have:
|
||||
// - The Qty/Unit cost/Disc%/VAT% <Input type="number"> cells carry no accessible name
|
||||
// (no htmlFor/aria-label) - located by column position within the row instead.
|
||||
// - On a PO-based line (line.poLineId set) Item/UOM render as plain text, not a Select -
|
||||
// fillFirstLine() only touches the item combobox when one is actually present (checked by
|
||||
// role alone, not name - see below).
|
||||
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||
// random subset of Select triggers' placeholder text for that page's lifetime, WITHOUT
|
||||
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
|
||||
// writeup). So triggers are located via comboboxByLabel() (label + role, no name lookup)
|
||||
// instead of getByRole("combobox", { name }) - the row-scoped item/uom/bin combos have no
|
||||
// adjacent label and are instead found by position, which is equally immune to the bug.
|
||||
export class GrnNewPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard/receiving/grn/new")
|
||||
}
|
||||
|
||||
async useDirectReceipt() {
|
||||
await clickToReveal(
|
||||
this.page.getByRole("button", { name: /direct receipt/i }),
|
||||
comboboxByLabel(this.page, "Vendor")
|
||||
)
|
||||
}
|
||||
|
||||
async useAgainstPo() {
|
||||
await clickToReveal(
|
||||
this.page.getByRole("button", { name: /against po/i }),
|
||||
comboboxByLabel(this.page, "Purchase order")
|
||||
)
|
||||
}
|
||||
|
||||
async selectVendor(name: string) {
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "Vendor"), name)
|
||||
}
|
||||
|
||||
async selectWarehouse(name: string) {
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), name)
|
||||
}
|
||||
|
||||
/** `docNo` is what the PO option renders (`{docNo} — Vendor #{vendorId} ({status})`) - not the numeric id. */
|
||||
async selectPurchaseOrder(docNo: string) {
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "Purchase order"), new RegExp(docNo))
|
||||
}
|
||||
|
||||
private firstRow() {
|
||||
return this.page.locator("table tbody tr").first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
|
||||
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
|
||||
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
|
||||
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
|
||||
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
|
||||
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
|
||||
* line needs it set explicitly or submit blocks with "Select a UOM".
|
||||
*/
|
||||
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
|
||||
const row = this.firstRow()
|
||||
const cells = row.locator("td")
|
||||
if (opts.item) {
|
||||
const itemCombo = cells.nth(0).getByRole("combobox")
|
||||
if (await itemCombo.count()) {
|
||||
await selectOption(this.page, itemCombo, opts.item)
|
||||
}
|
||||
}
|
||||
if (opts.uom) {
|
||||
const uomCombo = cells.nth(1).getByRole("combobox")
|
||||
if (await uomCombo.count()) {
|
||||
await selectOption(this.page, uomCombo, opts.uom)
|
||||
}
|
||||
}
|
||||
const numberInputs = row.locator('input[type="number"]')
|
||||
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
|
||||
if (opts.unitCost !== undefined) {
|
||||
await numberInputs.nth(1).fill(String(opts.unitCost)) // Unit cost
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole("button", { name: /create grn/i }).click()
|
||||
}
|
||||
}
|
||||
|
||||
export class GrnDetailPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async gotoById(grnId: number) {
|
||||
await this.page.goto(`/dashboard/receiving/grn/${grnId}`)
|
||||
}
|
||||
|
||||
/** GrnStatusBadge/HoldStatusBadge render the raw status string verbatim - exact match avoids
|
||||
* matching prose like "Confirmed — stock layers created" in the post-confirm success panel. */
|
||||
async expectStatus(status: "Draft" | "Confirmed") {
|
||||
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
await this.page.getByRole("button", { name: /confirm grn/i }).click()
|
||||
}
|
||||
|
||||
async releaseFirstOnHoldLine() {
|
||||
await this.page.getByRole("button", { name: /^release$/i }).first().click()
|
||||
}
|
||||
|
||||
async rejectFirstOnHoldLine() {
|
||||
await this.page.getByRole("button", { name: /^reject$/i }).first().click()
|
||||
}
|
||||
|
||||
async expectCreateReturnLink() {
|
||||
await expect(this.page.getByRole("link", { name: /create return/i })).toBeVisible()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Page, expect } from "@playwright/test"
|
||||
|
||||
export class LoginPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login")
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.page.locator("#email").fill(email)
|
||||
await this.page.locator("#password").fill(password)
|
||||
await this.page.getByRole("button", { name: /sign in/i }).click()
|
||||
}
|
||||
|
||||
async expectLoggedIn() {
|
||||
await expect(this.page).toHaveURL(/\/dashboard/)
|
||||
}
|
||||
|
||||
async expectError() {
|
||||
await expect(this.page.getByRole("alert")).toBeVisible()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Page, expect } from "@playwright/test"
|
||||
import { clickToReveal, clickToRevealWithReload, selectOption, comboboxByLabel, submitAndWait } from "../support/ui"
|
||||
|
||||
// Locators verified against Frontend/erp-system/app/dashboard/production/runs/page.tsx,
|
||||
// .../runs/[id]/page.tsx, .../runs/[id]/StageDrawer.tsx, and .../runs/[id]/RunActions.tsx.
|
||||
// Key DOM facts that shaped these locators:
|
||||
// - "Start Run" (the list page's dialog trigger, capital R) and "Start run" (the dialog's
|
||||
// submit button, lowercase r) both match a case-insensitive /start run/i once the dialog
|
||||
// is open (the trigger stays mounted behind it) - the submit click is scoped to
|
||||
// getByRole("dialog") to avoid a strict-mode double match.
|
||||
// - STAGE_STATUS_LABEL.InProgress is "In Progress" - the same text StageStatusLegend
|
||||
// always renders on the run detail page, so a run-status assertion of "In Progress"
|
||||
// collides with the legend. expectStatus() takes the first DOM match, which is always
|
||||
// the run-header badge (it renders before the legend section).
|
||||
// - AlertDialogContent's rejectForRework confirmation reuses "Reject for rework" as both
|
||||
// the trigger and the confirm button's label - first()/last() disambiguates, same as
|
||||
// cancelRun's "Cancel run" trigger/confirm pair.
|
||||
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||
// random subset of Select triggers' placeholder text for that page's lifetime, without
|
||||
// affecting their sibling <FieldLabel> or role="combobox" attribute (support/ui.ts has the
|
||||
// full writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup)
|
||||
// instead of getByRole("combobox", { name }); the scrap-reason Select has no adjacent
|
||||
// label, so it's found via its "Scrapped" sibling block instead.
|
||||
// - Every stage/run action button here fires an async POST that the UI only reflects once the
|
||||
// response lands (StageDrawer/RunActions' `submit()` wrapper) - submitAndWait() (support/ui.ts)
|
||||
// waits for that specific response instead of just the click event, so a test reading stock
|
||||
// right after clicking "Approve & receive" (etc.) doesn't race the backend commit.
|
||||
export class ProductionRunListPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard/production/runs")
|
||||
}
|
||||
|
||||
async openStartRunDialog() {
|
||||
await clickToReveal(
|
||||
this.page.getByRole("button", { name: /start run/i }),
|
||||
this.page.getByRole("dialog")
|
||||
)
|
||||
}
|
||||
|
||||
async startRun(opts: { template: string; targetQty: number; warehouse: string }) {
|
||||
await this.openStartRunDialog()
|
||||
const dialog = this.page.getByRole("dialog")
|
||||
await selectOption(this.page, comboboxByLabel(dialog, "Template"), opts.template)
|
||||
await dialog.locator("#target-qty").fill(String(opts.targetQty))
|
||||
await selectOption(this.page, comboboxByLabel(dialog, "Warehouse"), opts.warehouse)
|
||||
await submitAndWait(this.page, dialog.getByRole("button", { name: /^start run$/i }), "/production-runs")
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductionRunDetailPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async gotoById(runId: number) {
|
||||
await this.page.goto(`/dashboard/production/runs/${runId}`)
|
||||
}
|
||||
|
||||
async expectStatus(status: RegExp | string) {
|
||||
await expect(this.page.getByText(status).first()).toBeVisible()
|
||||
}
|
||||
|
||||
/** Opens the StageDrawer for a named stage node on the React Flow canvas. */
|
||||
async openStage(stageName: string) {
|
||||
await clickToReveal(
|
||||
this.page.getByText(stageName, { exact: true }),
|
||||
this.page.getByRole("button", { name: /save quantities/i })
|
||||
)
|
||||
}
|
||||
|
||||
/** The StageDrawer is a modal Sheet - run-level actions (Return leftover, Cancel run) sit
|
||||
* behind it and need it dismissed first. */
|
||||
async closeStageDrawer() {
|
||||
await this.page.keyboard.press("Escape")
|
||||
}
|
||||
|
||||
async saveQuantities() {
|
||||
// updateStageQuantities is a PUT, unlike every other stage action.
|
||||
await submitAndWait(this.page, this.page.getByRole("button", { name: /save quantities/i }), "/quantities", "PUT")
|
||||
}
|
||||
|
||||
async startStage() {
|
||||
await submitAndWait(this.page, this.page.getByRole("button", { name: /^start stage$/i }), "/start")
|
||||
}
|
||||
|
||||
async completeStage(opts: { producedQty: number; scrappedQty?: number }) {
|
||||
await this.page.getByRole("spinbutton", { name: /produced/i }).first().fill(String(opts.producedQty))
|
||||
if (opts.scrappedQty) {
|
||||
await this.page.getByRole("spinbutton", { name: /scrapped/i }).first().fill(String(opts.scrappedQty))
|
||||
const scrapBlock = this.page.getByText("Scrapped", { exact: true }).locator("../..")
|
||||
await selectOption(this.page, scrapBlock.getByRole("combobox"), /.+/)
|
||||
}
|
||||
await submitAndWait(this.page, this.page.getByRole("button", { name: /complete stage/i }), "/complete")
|
||||
}
|
||||
|
||||
async approveAndReceive() {
|
||||
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*receive/i }), "/approve")
|
||||
}
|
||||
|
||||
async approveAndTransfer() {
|
||||
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*transfer/i }), "/approve")
|
||||
}
|
||||
|
||||
async rejectForRework() {
|
||||
const button = this.page.getByRole("button", { name: /^reject for rework$/i })
|
||||
await clickToReveal(button.first(), this.page.getByRole("dialog"))
|
||||
await submitAndWait(this.page, button.last(), "/reject")
|
||||
}
|
||||
|
||||
async openReturnLeftoverDialog() {
|
||||
await clickToRevealWithReload(
|
||||
this.page,
|
||||
this.page.getByRole("button", { name: /return leftover/i }),
|
||||
this.page.getByRole("dialog")
|
||||
)
|
||||
}
|
||||
|
||||
async returnLeftover(opts: { material: string; qty: number; reason: string }) {
|
||||
await this.openReturnLeftoverDialog()
|
||||
const dialog = this.page.getByRole("dialog")
|
||||
await selectOption(this.page, comboboxByLabel(dialog, "Consumed material"), opts.material)
|
||||
await dialog.locator("#return-qty").fill(String(opts.qty))
|
||||
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
|
||||
await submitAndWait(this.page, dialog.getByRole("button", { name: /return to stock/i }), "/return-leftover")
|
||||
}
|
||||
|
||||
async cancelRun(opts: { reason: string; note?: string }) {
|
||||
const cancelRunButton = this.page.getByRole("button", { name: /^cancel run$/i })
|
||||
const dialog = this.page.getByRole("dialog")
|
||||
await clickToRevealWithReload(this.page, cancelRunButton.first(), dialog)
|
||||
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
|
||||
if (opts.note) await dialog.locator("#cancel-note").fill(opts.note)
|
||||
await submitAndWait(this.page, cancelRunButton.last(), "/cancel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Page, expect } from "@playwright/test"
|
||||
import { selectOption, comboboxByLabel } from "../support/ui"
|
||||
|
||||
// Locators verified against Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx,
|
||||
// .../transfers/[id]/page.tsx, and .../stock/adjustments/new/page.tsx.
|
||||
// Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||
// random subset of Select triggers' placeholder text for that page's lifetime, without
|
||||
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
|
||||
// writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup) instead
|
||||
// of getByRole("combobox", { name }); the row-scoped item combo has no adjacent label and is
|
||||
// instead found by position (it's the first combobox in the row).
|
||||
export class StockTransferNewPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard/stock/transfers/new")
|
||||
}
|
||||
|
||||
async fill(opts: { fromWarehouse: string; toWarehouse: string; item: string; qty: number }) {
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "From warehouse"), opts.fromWarehouse)
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "To warehouse"), opts.toWarehouse)
|
||||
|
||||
const row = this.page.locator("table tbody tr").first()
|
||||
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
|
||||
// The Qty <Input type="number"> carries no accessible name - it's the only number input in the row.
|
||||
await row.locator('input[type="number"]').fill(String(opts.qty))
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole("button", { name: /create transfer/i }).click()
|
||||
}
|
||||
}
|
||||
|
||||
export class StockTransferDetailPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async gotoById(transferId: number) {
|
||||
await this.page.goto(`/dashboard/stock/transfers/${transferId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* TransferStatusBadge renders the raw enum literal ("Draft" | "InTransit" | "Received") -
|
||||
* exact match, since "Received" is also a substring of the post-receive success panel's
|
||||
* heading ("Received — destination layers created").
|
||||
*/
|
||||
async expectStatus(status: "Draft" | "InTransit" | "Received") {
|
||||
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
async dispatch() {
|
||||
await this.page.getByRole("button", { name: /^dispatch$/i }).click()
|
||||
}
|
||||
|
||||
async receive() {
|
||||
await this.page.getByRole("button", { name: /^receive$/i }).click()
|
||||
}
|
||||
}
|
||||
|
||||
export class StockAdjustmentNewPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard/stock/adjustments/new")
|
||||
}
|
||||
|
||||
async fill(opts: { warehouse: string; reasonCode: string; item: string; qtyDelta: number }) {
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), opts.warehouse)
|
||||
await selectOption(this.page, comboboxByLabel(this.page, "Reason code"), opts.reasonCode)
|
||||
|
||||
const row = this.page.locator("table tbody tr").first()
|
||||
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
|
||||
await row.getByPlaceholder(/e\.g\. -15 or 50/i).fill(String(opts.qtyDelta))
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole("button", { name: /post adjustment/i }).click()
|
||||
}
|
||||
|
||||
async expectPosted() {
|
||||
await expect(this.page.getByRole("button", { name: /new adjustment/i })).toBeVisible()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
import { env, AUTH_STORAGE_STATE } from "./support/env"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./specs",
|
||||
fullyParallel: false, // specs share warehouse/item reference data via the ledger - keep runs serial per file
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
// Multiple workers hit the local dev backend concurrently across spec files and it can't
|
||||
// take it: confirmed reference-data GETs (e.g. /warehouses) intermittently 500 under 2+
|
||||
// workers against a plain `dotnet run` + local Postgres, and pass every time at workers: 1.
|
||||
// Bump this only against a backend that can actually take concurrent load (a real CI service
|
||||
// container, not a single dev-mode process).
|
||||
workers: 1,
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 10_000 },
|
||||
use: {
|
||||
baseURL: env.baseUrl,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /global\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"], storageState: AUTH_STORAGE_STATE },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { LoginPage } from "../pages/LoginPage"
|
||||
import { env } from "../support/env"
|
||||
|
||||
// Runs unauthenticated - unlike every other spec, it must not use the "chromium" project's
|
||||
// saved storageState, since it is exercising the login form itself.
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test.describe("Login", () => {
|
||||
test("valid credentials redirect to the dashboard", async ({ page }) => {
|
||||
const login = new LoginPage(page)
|
||||
await login.goto()
|
||||
await login.login(env.adminEmail, env.adminPassword)
|
||||
await login.expectLoggedIn()
|
||||
})
|
||||
|
||||
test("invalid password shows an inline error and stays on /login", async ({ page }) => {
|
||||
const login = new LoginPage(page)
|
||||
await login.goto()
|
||||
await login.login(env.adminEmail, "definitely-not-the-password")
|
||||
await login.expectError()
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test("session-expired redirect shows the amber notice", async ({ page }) => {
|
||||
await page.goto("/login?next=/dashboard/receiving/grn")
|
||||
await expect(page.getByText(/session is missing or expired/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
|
||||
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
|
||||
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
|
||||
|
||||
// Full cross-module lifecycle: GRN receipt -> Production consumes the received stock and
|
||||
// produces a finished good -> Stock Transfer moves the finished good to a second warehouse.
|
||||
// All four modules post to the same StockLayer/StockLedger tables (docs/10 C.9), so this is
|
||||
// the scenario most likely to catch a regression in one module's ledger posting breaking
|
||||
// another's downstream read - the thing the per-module suites (grn.spec.ts,
|
||||
// production.spec.ts, stock-transfers.spec.ts) can't see in isolation.
|
||||
test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||
let api: APIRequestContext
|
||||
let seeder: ApiSeeder
|
||||
let sourceWarehouse: Warehouse
|
||||
let destWarehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let rawItem: Item
|
||||
let finishedItem: Item
|
||||
let templateName: string
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await newApiContext()
|
||||
seeder = new ApiSeeder(api)
|
||||
sourceWarehouse = await seeder.firstWarehouse()
|
||||
destWarehouse = await seeder.secondWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor("Chained Flow Vendor")
|
||||
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
|
||||
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
|
||||
|
||||
const template = await seeder.createSingleStageTemplate({
|
||||
rawItemId: rawItem.itemId,
|
||||
finishedItemId: finishedItem.itemId,
|
||||
uomId: uom.uomId,
|
||||
})
|
||||
templateName = template.name
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api.dispose()
|
||||
})
|
||||
|
||||
test("receive raw material, run production, transfer the finished good", async ({ page }) => {
|
||||
// --- 1. GRN: receive the raw material into the source warehouse -----------------
|
||||
const grnNew = new GrnNewPage(page)
|
||||
await grnNew.goto()
|
||||
await grnNew.useDirectReceipt()
|
||||
await grnNew.selectVendor(vendor.name)
|
||||
await grnNew.selectWarehouse(sourceWarehouse.name)
|
||||
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
|
||||
await grnNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||
const grnDetail = new GrnDetailPage(page)
|
||||
await grnDetail.expectStatus("Draft")
|
||||
await grnDetail.confirm()
|
||||
await grnDetail.expectStatus("Confirmed")
|
||||
|
||||
const rawAfterGrn = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
|
||||
expect(rawAfterGrn.onHand).toBeCloseTo(100, 4)
|
||||
|
||||
// --- 2. Production: consume the raw material, produce the finished good ---------
|
||||
const runList = new ProductionRunListPage(page)
|
||||
await runList.goto()
|
||||
await runList.startRun({ template: templateName, targetQty: 20, warehouse: sourceWarehouse.name })
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||
const runDetail = new ProductionRunDetailPage(page)
|
||||
await runDetail.expectStatus(/in progress/i)
|
||||
|
||||
await runDetail.openStage("Assemble")
|
||||
await runDetail.saveQuantities()
|
||||
await runDetail.startStage()
|
||||
|
||||
const rawAfterStart = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
|
||||
expect(rawAfterStart.onHand).toBeLessThan(rawAfterGrn.onHand)
|
||||
|
||||
await runDetail.completeStage({ producedQty: 20 })
|
||||
await runDetail.approveAndReceive()
|
||||
|
||||
const finishedAfterRun = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
|
||||
expect(finishedAfterRun.onHand).toBeCloseTo(20, 4)
|
||||
|
||||
// --- 3. Stock Transfer: move the finished good to a second warehouse ------------
|
||||
const transferNew = new StockTransferNewPage(page)
|
||||
await transferNew.goto()
|
||||
await transferNew.fill({
|
||||
fromWarehouse: sourceWarehouse.name,
|
||||
toWarehouse: destWarehouse.name,
|
||||
item: finishedItem.name,
|
||||
qty: 20,
|
||||
})
|
||||
await transferNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||
const transferDetail = new StockTransferDetailPage(page)
|
||||
await transferDetail.expectStatus("Draft")
|
||||
await transferDetail.dispatch()
|
||||
await transferDetail.expectStatus("InTransit")
|
||||
await transferDetail.receive()
|
||||
await transferDetail.expectStatus("Received")
|
||||
|
||||
// --- 4. Final assertions across the whole chain ----------------------------------
|
||||
const finishedAtSource = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
|
||||
const finishedAtDest = await seeder.stockOnHand(finishedItem.itemId, destWarehouse.warehouseId)
|
||||
expect(finishedAtSource.onHand).toBeCloseTo(0, 4)
|
||||
expect(finishedAtDest.onHand).toBeCloseTo(20, 4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test as setup } from "@playwright/test"
|
||||
import { LoginPage } from "../pages/LoginPage"
|
||||
import { env, AUTH_STORAGE_STATE } from "../support/env"
|
||||
|
||||
// Runs once before the "chromium" project (see playwright.config.ts `dependencies`). Logs
|
||||
// in through the real UI form - the session is an httpOnly cookie (docs/11 §2.0), so there
|
||||
// is no token to inject; driving the form is the only way to obtain it - then saves cookies
|
||||
// to disk so every other spec starts already authenticated.
|
||||
setup("authenticate", async ({ page }) => {
|
||||
const login = new LoginPage(page)
|
||||
await login.goto()
|
||||
await login.login(env.adminEmail, env.adminPassword)
|
||||
await login.expectLoggedIn()
|
||||
|
||||
await page.context().storageState({ path: AUTH_STORAGE_STATE })
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api"
|
||||
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
|
||||
|
||||
// GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend
|
||||
// app/dashboard/receiving/grn/*). Covers direct + against-PO receipts, confirm posting to
|
||||
// the stock ledger, and per-line hold-status actions.
|
||||
test.describe("GRN", () => {
|
||||
let api: APIRequestContext
|
||||
let seeder: ApiSeeder
|
||||
let warehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let item: Item
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await newApiContext()
|
||||
seeder = new ApiSeeder(api)
|
||||
warehouse = await seeder.firstWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor()
|
||||
item = await seeder.createItem({ namePrefix: "GRN Test Item" })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api.dispose()
|
||||
})
|
||||
|
||||
test("direct receipt creates a Draft GRN, confirm posts stock", async ({ page }) => {
|
||||
const grnNew = new GrnNewPage(page)
|
||||
await grnNew.goto()
|
||||
await grnNew.useDirectReceipt()
|
||||
await grnNew.selectVendor(vendor.name)
|
||||
await grnNew.selectWarehouse(warehouse.name)
|
||||
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 })
|
||||
await grnNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||
const grnDetail = new GrnDetailPage(page)
|
||||
await grnDetail.expectStatus("Draft")
|
||||
|
||||
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
|
||||
await grnDetail.confirm()
|
||||
await grnDetail.expectStatus("Confirmed")
|
||||
|
||||
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
|
||||
})
|
||||
|
||||
test("against-PO receipt pre-fills vendor/warehouse from the PO", async ({ page }) => {
|
||||
const po = await seeder.createPurchaseOrder({
|
||||
vendorId: vendor.vendorId,
|
||||
warehouseId: warehouse.warehouseId,
|
||||
itemId: item.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 5,
|
||||
unitPrice: 40,
|
||||
})
|
||||
|
||||
const grnNew = new GrnNewPage(page)
|
||||
await grnNew.goto()
|
||||
await grnNew.useAgainstPo()
|
||||
await grnNew.selectPurchaseOrder(po.docNo)
|
||||
await grnNew.fillFirstLine({ item: item.name, qty: 5 })
|
||||
await grnNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||
const grnDetail = new GrnDetailPage(page)
|
||||
await grnDetail.expectStatus("Draft")
|
||||
})
|
||||
|
||||
test("rejecting a Draft GRN's blocked submit (missing warehouse) keeps the user on the form", async ({ page }) => {
|
||||
const grnNew = new GrnNewPage(page)
|
||||
await grnNew.goto()
|
||||
await grnNew.useDirectReceipt()
|
||||
await grnNew.selectVendor(vendor.name)
|
||||
// Warehouse intentionally left unselected.
|
||||
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 })
|
||||
await grnNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/)
|
||||
})
|
||||
|
||||
test("releasing an on-hold line clears the hold and makes stock available", async ({ page }) => {
|
||||
const grn = await seeder.receiveStockOnHold({
|
||||
warehouseId: warehouse.warehouseId,
|
||||
vendorId: vendor.vendorId,
|
||||
itemId: item.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 8,
|
||||
unitCost: 12,
|
||||
})
|
||||
|
||||
const grnDetail = new GrnDetailPage(page)
|
||||
await grnDetail.gotoById(grn.grnId)
|
||||
await grnDetail.expectStatus("Confirmed")
|
||||
|
||||
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
expect(before.available).toBeLessThan(before.onHand) // held stock is on-hand but not available
|
||||
|
||||
await grnDetail.releaseFirstOnHoldLine()
|
||||
|
||||
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
expect(after.available).toBeCloseTo(before.available + 8, 4)
|
||||
})
|
||||
|
||||
test("rejecting an on-hold line surfaces a Create Return link", async ({ page }) => {
|
||||
const grn = await seeder.receiveStockOnHold({
|
||||
warehouseId: warehouse.warehouseId,
|
||||
vendorId: vendor.vendorId,
|
||||
itemId: item.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 3,
|
||||
unitCost: 12,
|
||||
})
|
||||
|
||||
const grnDetail = new GrnDetailPage(page)
|
||||
await grnDetail.gotoById(grn.grnId)
|
||||
await grnDetail.expectStatus("Confirmed")
|
||||
|
||||
await grnDetail.rejectFirstOnHoldLine()
|
||||
await grnDetail.expectCreateReturnLink()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
|
||||
|
||||
// Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs,
|
||||
// Frontend app/dashboard/production/runs/*). Uses a minimal single-stage template (one
|
||||
// stage that is both entry and terminal - see ApiSeeder.createSingleStageTemplate) so the
|
||||
// stage-action sequence (start -> complete -> approve & receive) is exercised without
|
||||
// needing a multi-stage graph.
|
||||
test.describe("Production runs", () => {
|
||||
let api: APIRequestContext
|
||||
let seeder: ApiSeeder
|
||||
let warehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let rawItem: Item
|
||||
let finishedItem: Item
|
||||
let templateName: string
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await newApiContext()
|
||||
seeder = new ApiSeeder(api)
|
||||
warehouse = await seeder.firstWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor()
|
||||
rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" })
|
||||
finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" })
|
||||
|
||||
// Give the run something to consume.
|
||||
await seeder.receiveStock({
|
||||
warehouseId: warehouse.warehouseId,
|
||||
vendorId: vendor.vendorId,
|
||||
itemId: rawItem.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 100,
|
||||
unitCost: 20,
|
||||
})
|
||||
|
||||
const template = await seeder.createSingleStageTemplate({
|
||||
rawItemId: rawItem.itemId,
|
||||
finishedItemId: finishedItem.itemId,
|
||||
uomId: uom.uomId,
|
||||
})
|
||||
templateName = template.name
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api.dispose()
|
||||
})
|
||||
|
||||
test("start run -> complete stage -> approve & receive posts finished-good stock", async ({ page }) => {
|
||||
const list = new ProductionRunListPage(page)
|
||||
await list.goto()
|
||||
await list.startRun({ template: templateName, targetQty: 10, warehouse: warehouse.name })
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||
const detail = new ProductionRunDetailPage(page)
|
||||
await detail.expectStatus(/in progress/i)
|
||||
|
||||
await detail.openStage("Assemble")
|
||||
await detail.saveQuantities()
|
||||
await detail.startStage()
|
||||
|
||||
const before = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
|
||||
|
||||
await detail.completeStage({ producedQty: 10 })
|
||||
await detail.approveAndReceive()
|
||||
|
||||
const after = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
|
||||
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
|
||||
})
|
||||
|
||||
test("cancel run stops further stage actions", async ({ page }) => {
|
||||
const list = new ProductionRunListPage(page)
|
||||
await list.goto()
|
||||
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||
|
||||
const detail = new ProductionRunDetailPage(page)
|
||||
await detail.cancelRun({ reason: "Production Run Cancelled", note: "E2E cancel test" })
|
||||
await detail.expectStatus(/cancelled/i)
|
||||
})
|
||||
|
||||
test("return leftover raw material posts the unused quantity back to stock", async ({ page }) => {
|
||||
const list = new ProductionRunListPage(page)
|
||||
await list.goto()
|
||||
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||
|
||||
const detail = new ProductionRunDetailPage(page)
|
||||
await detail.openStage("Assemble")
|
||||
await detail.saveQuantities()
|
||||
await detail.startStage() // consumes the raw-material FIFO layers, making them returnable
|
||||
await detail.closeStageDrawer()
|
||||
|
||||
const before = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
|
||||
|
||||
await detail.returnLeftover({ material: rawItem.name, qty: 1, reason: "Production Leftover Return" })
|
||||
|
||||
const after = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
|
||||
expect(after.onHand).toBeCloseTo(before.onHand + 1, 4)
|
||||
})
|
||||
|
||||
test("reject for rework resets the run and increments the rework count", async ({ page }) => {
|
||||
const list = new ProductionRunListPage(page)
|
||||
await list.goto()
|
||||
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||
|
||||
const detail = new ProductionRunDetailPage(page)
|
||||
await detail.openStage("Assemble")
|
||||
await detail.saveQuantities()
|
||||
await detail.startStage()
|
||||
await detail.completeStage({ producedQty: 5 }) // stage -> Done, and terminal (single-stage template)
|
||||
|
||||
await detail.rejectForRework()
|
||||
await detail.expectStatus(/rework #1/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||
import { StockAdjustmentNewPage } from "../pages/StockPages"
|
||||
|
||||
// Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs,
|
||||
// Frontend app/dashboard/stock/adjustments/new): posts immediately, no draft state
|
||||
// (Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs - QtyDelta is a signed base-UOM delta).
|
||||
test.describe("Stock adjustments", () => {
|
||||
let api: APIRequestContext
|
||||
let seeder: ApiSeeder
|
||||
let warehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let item: Item
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await newApiContext()
|
||||
seeder = new ApiSeeder(api)
|
||||
warehouse = await seeder.firstWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor()
|
||||
item = await seeder.createItem({ namePrefix: "Adjustment Test Item" })
|
||||
|
||||
await seeder.receiveStock({
|
||||
warehouseId: warehouse.warehouseId,
|
||||
vendorId: vendor.vendorId,
|
||||
itemId: item.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 20,
|
||||
unitCost: 30,
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api.dispose()
|
||||
})
|
||||
|
||||
test("positive adjustment increases on-hand and shows the posted doc number", async ({ page }) => {
|
||||
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
|
||||
const newPage = new StockAdjustmentNewPage(page)
|
||||
await newPage.goto()
|
||||
await newPage.fill({ warehouse: warehouse.name, reasonCode: "System Correction", item: item.name, qtyDelta: 5 })
|
||||
await newPage.submit()
|
||||
|
||||
await newPage.expectPosted()
|
||||
|
||||
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
expect(after.onHand).toBeCloseTo(before.onHand + 5, 4)
|
||||
})
|
||||
|
||||
test("negative adjustment decreases on-hand", async ({ page }) => {
|
||||
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
|
||||
const newPage = new StockAdjustmentNewPage(page)
|
||||
await newPage.goto()
|
||||
await newPage.fill({ warehouse: warehouse.name, reasonCode: "Damage", item: item.name, qtyDelta: -3 })
|
||||
await newPage.submit()
|
||||
|
||||
await newPage.expectPosted()
|
||||
|
||||
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||
expect(after.onHand).toBeCloseTo(before.onHand - 3, 4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
|
||||
|
||||
// Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend
|
||||
// app/dashboard/stock/transfers/*): Draft -> Dispatch -> Receive, moving FIFO layers
|
||||
// between warehouses.
|
||||
test.describe("Stock transfers", () => {
|
||||
let api: APIRequestContext
|
||||
let seeder: ApiSeeder
|
||||
let srcWarehouse: Warehouse
|
||||
let destWarehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let item: Item
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await newApiContext()
|
||||
seeder = new ApiSeeder(api)
|
||||
srcWarehouse = await seeder.firstWarehouse()
|
||||
destWarehouse = await seeder.secondWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor()
|
||||
item = await seeder.createItem({ namePrefix: "Transfer Test Item" })
|
||||
|
||||
await seeder.receiveStock({
|
||||
warehouseId: srcWarehouse.warehouseId,
|
||||
vendorId: vendor.vendorId,
|
||||
itemId: item.itemId,
|
||||
uomId: uom.uomId,
|
||||
qty: 50,
|
||||
unitCost: 15,
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api.dispose()
|
||||
})
|
||||
|
||||
test("create -> dispatch -> receive moves stock between warehouses", async ({ page }) => {
|
||||
const before = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
|
||||
|
||||
const newPage = new StockTransferNewPage(page)
|
||||
await newPage.goto()
|
||||
await newPage.fill({ fromWarehouse: srcWarehouse.name, toWarehouse: destWarehouse.name, item: item.name, qty: 10 })
|
||||
await newPage.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||
const detail = new StockTransferDetailPage(page)
|
||||
await detail.expectStatus("Draft")
|
||||
|
||||
await detail.dispatch()
|
||||
await detail.expectStatus("InTransit")
|
||||
|
||||
const afterDispatch = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
|
||||
// Dispatch consumes the source FIFO layers immediately (Backend/ERPCore/Services/Stock/
|
||||
// StockService.cs: "Dispatch already consumed the source layers, so this stock has left
|
||||
// onHand") - inTransit is reported for visibility only, not held back from onHand.
|
||||
expect(afterDispatch.onHand).toBeCloseTo(before.onHand - 10, 4)
|
||||
|
||||
await detail.receive()
|
||||
await detail.expectStatus("Received")
|
||||
|
||||
const destAfter = await seeder.stockOnHand(item.itemId, destWarehouse.warehouseId)
|
||||
expect(destAfter.onHand).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test("dispatch fails with insufficient stock and the transfer stays Draft", async ({ page }) => {
|
||||
const newPage = new StockTransferNewPage(page)
|
||||
await newPage.goto()
|
||||
await newPage.fill({
|
||||
fromWarehouse: srcWarehouse.name,
|
||||
toWarehouse: destWarehouse.name,
|
||||
item: item.name,
|
||||
qty: 999_999,
|
||||
})
|
||||
await newPage.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||
const detail = new StockTransferDetailPage(page)
|
||||
await detail.expectStatus("Draft")
|
||||
|
||||
await detail.dispatch()
|
||||
|
||||
// FifoCostingService rejects with 409 STOCK_NEGATIVE_BLOCKED - the frontend surfaces the
|
||||
// error and leaves the transfer in Draft rather than advancing it.
|
||||
await detail.expectStatus("Draft")
|
||||
await expect(page.getByRole("button", { name: /^dispatch$/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { APIRequestContext, expect, request } from "@playwright/test"
|
||||
import { env, AUTH_STORAGE_STATE } from "./env"
|
||||
|
||||
/**
|
||||
* Standalone APIRequestContext for use in `test.beforeAll`, where the test-scoped `request`
|
||||
* fixture isn't available. Reuses the same storageState the "setup" project produced, so it
|
||||
* is already authenticated. Caller must `.dispose()` it in `afterAll`.
|
||||
*/
|
||||
export async function newApiContext(): Promise<APIRequestContext> {
|
||||
return request.newContext({ baseURL: env.baseUrl, storageState: AUTH_STORAGE_STATE })
|
||||
}
|
||||
|
||||
// Thin wrapper over the same `/api/v1` surface `Frontend/erp-system/lib/api/*.ts` calls,
|
||||
// used to seed/verify data directly against the backend so specs don't have to build every
|
||||
// prerequisite (vendors, POs, templates) by driving the UI. `request` must already carry
|
||||
// the authenticated session cookie - either via the "setup" project's storageState, or by
|
||||
// passing a context created after `AuthApi.login`.
|
||||
const API_BASE = "/api/v1"
|
||||
|
||||
export interface Warehouse {
|
||||
warehouseId: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
vendorId: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
baseUomId: number
|
||||
}
|
||||
|
||||
export interface Uom {
|
||||
uomId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
categoryId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Suffixes every seeded code/SKU with a run-unique token so parallel/rerun specs never collide. */
|
||||
export function uniqueSuffix(): string {
|
||||
return `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||
}
|
||||
|
||||
export class ApiSeeder {
|
||||
constructor(private readonly request: APIRequestContext) {}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const res = await this.request.get(`${API_BASE}${path}`)
|
||||
expect(res.ok(), `GET ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||
return res.json()
|
||||
}
|
||||
|
||||
private async post<T>(path: string, data: unknown): Promise<T> {
|
||||
const res = await this.request.post(`${API_BASE}${path}`, { data })
|
||||
expect(res.ok(), `POST ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// --- reference data (relies on DataSeeder's MAIN/SHOP/PCS/BOX/General Goods seed) -----
|
||||
|
||||
async firstWarehouse(): Promise<Warehouse> {
|
||||
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No warehouses found - expected DataSeeder's MAIN warehouse to exist.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
async secondWarehouse(): Promise<Warehouse> {
|
||||
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=10")
|
||||
if (page.items.length < 2) throw new Error("Need at least 2 warehouses (DataSeeder seeds MAIN + SHOP).")
|
||||
return page.items[1]
|
||||
}
|
||||
|
||||
async firstUom(): Promise<Uom> {
|
||||
const page = await this.get<{ items: Uom[] }>("/uoms?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No UOMs found - expected DataSeeder's PCS uom to exist.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
async firstCategory(): Promise<Category> {
|
||||
const page = await this.get<{ items: Category[] }>("/categories?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No categories found - expected DataSeeder's General Goods category.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
// --- writes used to build test fixtures -------------------------------------------
|
||||
|
||||
async createVendor(namePrefix = "E2E Vendor"): Promise<Vendor> {
|
||||
const suffix = uniqueSuffix()
|
||||
return this.post<Vendor>("/vendors", {
|
||||
code: `E2E-V-${suffix}`,
|
||||
name: `${namePrefix} ${suffix}`,
|
||||
currency: "LKR",
|
||||
})
|
||||
}
|
||||
|
||||
async createItem(opts: { namePrefix?: string; categoryId?: number; baseUomId?: number } = {}): Promise<Item> {
|
||||
const suffix = uniqueSuffix()
|
||||
const categoryId = opts.categoryId ?? (await this.firstCategory()).categoryId
|
||||
const baseUomId = opts.baseUomId ?? (await this.firstUom()).uomId
|
||||
return this.post<Item>("/items", {
|
||||
sku: `E2E-SKU-${suffix}`,
|
||||
name: `${opts.namePrefix ?? "E2E Item"} ${suffix}`,
|
||||
categoryId,
|
||||
baseUomId,
|
||||
stockNature: "Stocked",
|
||||
trackingMode: "None",
|
||||
})
|
||||
}
|
||||
|
||||
/** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */
|
||||
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||
const grn = await this.post<{ grnId: number }>("/grns", {
|
||||
vendorId: opts.vendorId,
|
||||
warehouseId: opts.warehouseId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
qty: opts.qty,
|
||||
unitCost: opts.unitCost,
|
||||
discountPct: 0,
|
||||
vatPct: 0,
|
||||
holdStatus: "Available",
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||
return grn
|
||||
}
|
||||
|
||||
/** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */
|
||||
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||
const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", {
|
||||
vendorId: opts.vendorId,
|
||||
warehouseId: opts.warehouseId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
qty: opts.qty,
|
||||
unitCost: opts.unitCost,
|
||||
discountPct: 0,
|
||||
vatPct: 0,
|
||||
holdStatus: "OnHold",
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||
return grn
|
||||
}
|
||||
|
||||
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) {
|
||||
return this.post<{ poId: number; docNo: string }>("/purchase-orders", {
|
||||
vendorId: opts.vendorId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
warehouseId: opts.warehouseId,
|
||||
qty: opts.qty,
|
||||
unitPrice: opts.unitPrice,
|
||||
tax: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal single-stage template: one stage that is both entry and terminal, one Stock
|
||||
* input (the raw material) and one item-bearing output (the finished good) - the
|
||||
* smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/
|
||||
* ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output).
|
||||
*/
|
||||
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) {
|
||||
const suffix = uniqueSuffix()
|
||||
return this.post<{ templateId: number; code: string; name: string }>("/production-templates", {
|
||||
code: `E2E-TPL-${suffix}`,
|
||||
name: `E2E Template ${suffix}`,
|
||||
stages: [
|
||||
{
|
||||
key: "stage-1",
|
||||
name: "Assemble",
|
||||
estimatedMinutes: 10,
|
||||
posX: 0,
|
||||
posY: 0,
|
||||
fieldDefs: [],
|
||||
inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
annotations: [],
|
||||
})
|
||||
}
|
||||
|
||||
async stockOnHand(itemId: number, warehouseId: number) {
|
||||
return this.get<{ onHand: number; available: number }>(`/stock/on-hand?itemId=${itemId}&warehouseId=${warehouseId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "node:path"
|
||||
import dotenv from "dotenv"
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, "../.env.e2e") })
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`Missing required env var ${name} - copy .env.e2e.example to .env.e2e and fill it in.`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const env = {
|
||||
baseUrl: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
apiUrl: process.env.E2E_API_URL ?? "http://localhost:5224",
|
||||
get adminEmail() {
|
||||
return required("E2E_ADMIN_EMAIL")
|
||||
},
|
||||
get adminPassword() {
|
||||
return required("E2E_ADMIN_PASSWORD")
|
||||
},
|
||||
}
|
||||
|
||||
export const AUTH_STORAGE_STATE = path.resolve(__dirname, "../.auth/admin.json")
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Page, Locator } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Root cause (confirmed via a repro script capturing `page.on("pageerror")`): every load of
|
||||
* these pages throws a genuine React hydration error ("Minified React error #418" - text
|
||||
* content mismatch between server and client render) - it is NOT intermittent. What IS
|
||||
* unpredictable is its effect: hydration recovery blanks the placeholder text of a random
|
||||
* subset of that page's Select triggers, but leaves everything else (the sibling <Label>/
|
||||
* <FieldLabel>, the trigger's role="combobox" attribute, the DOM structure) intact. So a
|
||||
* reload-until-clean strategy never terminates (confirmed: reloading never once produced a
|
||||
* "clean" load), and a bare `getByRole("combobox", { name: ... })` is unreliable because the
|
||||
* accessible name it depends on is exactly what gets blanked.
|
||||
*
|
||||
* The fix is to stop depending on that name at all: every Select trigger in this app sits as
|
||||
* an immediate sibling of a stable, always-intact label element, so `comboboxByLabel()` finds
|
||||
* the trigger via that label + role="combobox" alone. `retryClick`/`selectOption`/
|
||||
* `clickToReveal` remain useful as defense-in-depth for ordinary timing races (dialogs
|
||||
* mounting, popups opening) that are unrelated to this hydration bug.
|
||||
*/
|
||||
export function comboboxByLabel(scope: Page | Locator, labelText: string): Locator {
|
||||
return scope.getByText(labelText, { exact: true }).locator("..").getByRole("combobox").first()
|
||||
}
|
||||
|
||||
/**
|
||||
* `trigger.click()` gets an explicit, short per-attempt timeout deliberately: without one, a
|
||||
* momentarily-disabled/not-yet-actionable button (e.g. a trigger that's disabled for one tick
|
||||
* after navigation before client state settles) lets a SINGLE click() call sit and retry
|
||||
* internally for the whole remaining test timeout, so this loop never reaches a second attempt
|
||||
* - confirmed happening on "Cancel run" right after starting a run. A short click timeout lets
|
||||
* the loop actually cycle through multiple real attempts within the test's time budget.
|
||||
*/
|
||||
export async function retryClick(trigger: Locator, verify: () => Promise<void>, attempts = 5) {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
await trigger.click({ timeout: 3000 })
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await verify()
|
||||
return
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Select trigger and picks an option by name. Deliberately a single click, not a
|
||||
* retryClick loop: re-clicking an already-open Select trigger toggles it shut, and if the
|
||||
* option name is ever wrong the popup's portal can end up overlaying the trigger, making
|
||||
* Playwright's actionability check for a second click hang indefinitely (confirmed while
|
||||
* testing this file) instead of failing fast. `comboboxByLabel` already makes the trigger
|
||||
* lookup itself reliable, so a plain click here is both simpler and safer.
|
||||
*/
|
||||
export async function selectOption(page: Page, trigger: Locator, optionName: string | RegExp) {
|
||||
// .first() covers callers that intentionally pass a name matching multiple options (e.g. "pick
|
||||
// any scrap reason") - for the common single-match case it's a no-op.
|
||||
const option = page.getByRole("option", { name: optionName }).first()
|
||||
await trigger.click()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
/** Clicks a trigger that's expected to reveal `target` (a dialog, a newly-mounted control), retrying the click. */
|
||||
export async function clickToReveal(trigger: Locator, target: Locator) {
|
||||
await retryClick(trigger, () => target.waitFor({ state: "visible", timeout: 1500 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `clickToReveal`, but escalates to a full `page.reload()` between rounds when the
|
||||
* trigger itself never becomes actionable within a round - not just "the popup didn't open"
|
||||
* but "the trigger stayed disabled" or "never rendered at all". Confirmed on the production run
|
||||
* detail page's "Cancel run"/"Return leftover" buttons (RunActions.tsx, gated on
|
||||
* `run.status === "InProgress"`): occasionally that gate/enabled-state renders wrong for the
|
||||
* rest of a page's life - the same class of one-shot render corruption as the hydration bug
|
||||
* documented above, just hitting a component's disabled/mounted state instead of a Select's
|
||||
* placeholder text. A reload gets a fresh render attempt; `trigger`/`target` are re-queried
|
||||
* live each round since Playwright locators aren't tied to a specific DOM snapshot.
|
||||
*/
|
||||
export async function clickToRevealWithReload(page: Page, trigger: Locator, target: Locator, reloadAttempts = 3) {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < reloadAttempts; i++) {
|
||||
try {
|
||||
await clickToReveal(trigger, target)
|
||||
return
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
if (i < reloadAttempts - 1) await page.reload()
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stage-action / document-action button in this app fires an async POST and only updates
|
||||
* the DOM once the response comes back (`onActed()`/`onSuccess()` refetch pattern) - Playwright's
|
||||
* `.click()` resolves as soon as the click event dispatches, NOT once that request settles. A
|
||||
* test that clicks "Approve & receive" and immediately reads stock through a separate API call
|
||||
* can race ahead of the backend commit and observe pre-action state (confirmed: production run
|
||||
* stock checks reading 0 immediately after a click the UI later shows as successful). Wrapping
|
||||
* the click in `page.waitForResponse` for the specific endpoint makes the helper actually wait
|
||||
* for the request that matters, not just the DOM event.
|
||||
*/
|
||||
export async function submitAndWait(page: Page, trigger: Locator, urlIncludes: string, method: "POST" | "PUT" = "POST") {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((res) => res.url().includes(urlIncludes) && res.request().method() === method),
|
||||
trigger.click(),
|
||||
])
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -520,6 +520,7 @@ that role sees the sidebar entries — normal onboarding, not a bug.
|
||||
spread across the available width instead of stretching a single narrow column. Modals
|
||||
(`ChequePageDialog`/`ReceivedChequeDialog`) were deliberately left at their existing fixed width —
|
||||
a dialog is supposed to stay narrow, this complaint was about full-page create forms only.
|
||||
- [x] **Cheque Management status/type fields were displaying raw integers, not names (2026-08-05, user-reported + confirmed live) — fixed by mapping GL's response integers to this frontend's string enums at the API boundary.** User supplied GL's own `06_Enums_Reference.md`: GL has no global `JsonStringEnumConverter`, so while a JSON-**body** enum field is independently declared `string` server-side (and a query-string enum filter binds natively by name — both already correct here, unaffected), a real enum-typed **response** DTO property serializes as its raw underlying integer with no converter to turn it back into a name. `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are exactly that — genuine DB-backed enum properties on GL's response DTOs — so every one of them was arriving as `1`/`2`/`3`/... instead of `"Active"`/`"Issued"`/`"Supplier"`, silently breaking every `===` comparison this frontend does against its own string enums (list badges, the create-form's own `<Select>` values, and both dialogs' status-based available-actions logic), not just the visible label. Added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (`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`), keyed by the exact integer values `06_Enums_Reference.md` documents. Applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing what GL's JSON for these fields actually is: `number`/`number | null`) and `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers, wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one of these shapes — the translation happens once, at the API boundary, so every existing page/dialog/badge map keeps comparing against the same string enum values as before and needed zero changes itself. Checked every other enum in `06_Enums_Reference.md`'s "Persisted enums" table (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) against this frontend — none are consumed anywhere (no Journal Entry/Fiscal Period/Tax Code/Fixed Asset/Audit Log UI exists here), so Cheque Management is the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
- [ ] **Not done — internal ERPCore→GL wiring.** Unrelated to this revision, still deferred
|
||||
(`docs/12` §6).
|
||||
- [ ] **Deferred — bank/cash account edit.** Needs GL service changes first (§4); now needs them for
|
||||
|
||||
Reference in New Issue
Block a user