Complete all for Items

This commit is contained in:
2026-07-17 14:27:51 +05:30
parent f72b24fcaa
commit 62a5d857de
103 changed files with 2540 additions and 3259 deletions
+31 -2
View File
@@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
{
await _users.LogoutUserAsync(request, ct);
var userId = request?.UserId ?? ResolveTokenUserId();
if (userId is not null)
{
try
{
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
}
catch (DomainException)
{
// Upstream unreachable or already-revoked — fall through and clear anyway.
}
}
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+16 -2
View File
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
+5 -1
View File
@@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse
public sealed class LogoutRequest
{
[Required] public Guid UserId { get; set; }
/// <summary>
/// Optional: AuthHex returns no <c>userId</c> on login, so browsers cannot supply one.
/// When omitted, the controller resolves it from the session token's UserId claim.
/// </summary>
public Guid? UserId { get; set; }
}
+5
View File
@@ -13,6 +13,11 @@ public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
+10 -1
View File
@@ -14,12 +14,21 @@ public sealed record ItemListItemDto(
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
@@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
public sealed record RfqSummaryDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
+7 -1
View File
@@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
+6 -1
View File
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
+54 -8
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs);
}
@@ -55,8 +55,11 @@ public sealed class AuthUserService : IAuthUserService
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
/// <summary>The controller resolves the id (from body or token claim) before calling here.</summary>
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
=> _authHex.LogoutUserAsync(request.UserId, ct);
=> request.UserId is null
? Task.CompletedTask
: _authHex.LogoutUserAsync(request.UserId.Value, ct);
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
+26 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
_uow = uow;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
@@ -175,7 +200,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+27
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
_uow = uow;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
public interface IRequisitionService
{
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default);
Task<RequisitionDto?> GetAsync(int requisitionId, CancellationToken ct = default);
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
Task<RequisitionDto> SubmitAsync(int requisitionId, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>RFQ &amp; vendor-quotation business logic (docs/11 §3.2).</summary>
public interface IRfqService
{
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default);
@@ -8,8 +8,13 @@ public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
+6
View File
@@ -89,6 +89,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -131,6 +132,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -351,5 +353,9 @@ public sealed class ItemService : IItemService
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
@@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new PurchaseReturnSummaryDto(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
}, ct);
// Map ledger ids after commit so they are populated.
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
}
@@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService
_uow = uow;
}
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default)
{
var q = _requisitions.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RequisitionId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt))
.Select(r => new RequisitionSummaryDto(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
+26
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService
_uow = uow;
}
public async Task<PagedResponse<RfqSummaryDto>> ListAsync(
PageQuery query, RfqStatus? status, CancellationToken ct = default)
{
var q = _rfqs.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RfqId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RfqSummaryDto(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.Lines.Count,
// Correlated subquery: there is no Rfq.Quotations navigation to count.
_quotations.Query().Count(qt => qt.RfqId == r.RfqId)))
.ToListAsync(ct);
return PagedResponse<RfqSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
+72 -1
View File
@@ -50,14 +50,85 @@ public sealed class StockService : IStockService
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
/// <summary>
/// On-hand across every (item, warehouse) pair that holds stock — backs the Stock
/// Enquiry list. Deliberately set-based: four grouped queries regardless of page size,
/// rather than calling <see cref="GetOnHandAsync"/> per row (which would be N+1).
/// Pairs are sourced from <c>StockLayer</c>, so an item that never had a receipt in a
/// warehouse simply does not appear.
/// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
{
var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) });
var total = await grouped.CountAsync(ct);
var page = await grouped
.OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
if (page.Count == 0)
return PagedResponse<StockOnHandDto>.Create([], query.Page, query.PageSize, total);
// Filtering by the page's ids gives a superset (the cross-product of both lists);
// the join below narrows it back to the actual pairs.
var itemIds = page.Select(p => p.ItemId).Distinct().ToList();
var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList();
var onHold = (await _layers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var inTransit = (await _transferLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId)
&& l.Transfer != null
&& warehouseIds.Contains(l.Transfer.SrcWarehouseId)
&& l.Transfer.Status == TransferStatus.InTransit)
.GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var asOf = DateTime.UtcNow;
var rows = page.Select(p =>
{
var key = (p.ItemId, p.WarehouseId);
var hold = onHold.GetValueOrDefault(key);
var transit = inTransit.GetValueOrDefault(key);
const decimal reserved = 0m;
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
return new StockOnHandDto(
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
}).ToList();
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically
// (docs/10 C.9), so this is the only way to ask "what did document X post?" —
// needed by any screen that reports on a document's costed movements.
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType);
if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LedgerId)
+27 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService
_uow = uow;
}
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
{
var q = _transfers.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
return PagedResponse<TransferSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
@@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}
+16 -4
View File
@@ -59,7 +59,18 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table.
> ### 2026-07-17 — read endpoints added so the frontend could be connected
> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created.
> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse<T>.Create`) with matching `*SummaryDto`s carrying a `lineCount`.
> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do.
> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history.
> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged.
> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens.
> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser.
> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards.
> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4.
## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
@@ -173,8 +184,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision.
- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register``200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN``403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.
- **ROOT CAUSE FOUND (2026-07-16), in `ERP_Auth_Service` two independent bugs, both one-liners. Not fixed here: different project, outside this repo's scope.** Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`.
1. **The password is never stored.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 is commented out** (`//PasswordHash = PasswordHash`). Every registered user lands in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always throws `"Invalid credentials"`. Uncommenting line 116 should fix login outright. Note existing users are unrecoverable — their hashes were never written — so they need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836).
- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them).
- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy.
- **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`.
1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836).
2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic`**not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 5665, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: "<username>"` will not resolve a user; only email/mobile/NIC will.
- **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed.
+51 -10
View File
@@ -5,20 +5,23 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## 0. Foundation
- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below)
- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`).
- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult<T>` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific)
- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*``BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example`**not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.)
- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over.
- [x] **Route guard** (`proxy.ts` — Next 16's rename of `middleware.ts`; the old name still works but warns). Redirects `/dashboard/*` to `/login?next=…` when the `erp_at` cookie is absent. **Presence check only** — the cookie is httpOnly and the JWT is RS256, so the edge cannot validate it; the API stays the authority.
- [x] **Auth** (`lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`) — real login/logout. No token is stored: the session is httpOnly cookies. `lib/auth-session.ts` caches the user *profile* in localStorage for the Header, because there is no `GET /auth/me` and the user object only arrives in the login response. It is display data, not a credential.
- [x] Shared TS types mirroring API DTOs (`types/{common,master-data,procurement,grn,stock,auth}.ts`) — **reconciled field-by-field against the live schemas 2026-07-17**; see the entry below for what had drifted.
- [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3)
- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error
> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built.
- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection. **Fixed 2026-07-17:** generic framework codes (`conflict`/`not_found`/`validation_error`) were shadowing the server's specific `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes now lose to `detail`; specific domain codes still win.
> **⚠️ The 2026-07-15 note below is HISTORY, not current state.** The fetch infrastructure was rebuilt on 2026-07-17 and `lib/api/mock-data.ts` is deleted — see "2026-07-17 — connected to the real API" at the bottom of this file. Its parting advice (re-verify the RFQ contract; expect to rebuild the client from scratch) was followed and proved correct.
>
> **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult<T>` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass).
>
> **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist.
## 1. Auth
- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage
- [x] Login screen — **wired 2026-07-17** to `POST /auth/login`. Previously it `console.log`'d the plaintext password and pushed to `/dashboard` unconditionally; any schema-valid input "logged in". Now: real call, session cookies, server errors surfaced, `?next=` honoured (same-origin paths only — an absolute URL there would be an open redirect).
- [x] Route guard (`proxy.ts`) + real logout in `components/Layouts/Header.tsx` — the Header no longer hardcodes `john52martinez@gmail.com`, and "Log out" is a real `POST /auth/logout` rather than a `<Link href="/login">`.
- [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API
- [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API
- [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
@@ -46,7 +49,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed`
- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn`
> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below).
> **⚠️ The two notes below are HISTORY (2026-07-13).** The GRN backend exists and these screens call it as of 2026-07-17; `GET /grns` + `GET /grns/{id}` are real, and GRN edit/delete were removed because the API has no `PUT`/`DELETE`. The FIFO engine they describe as living in `mock-data.ts` is deleted — the server owns it.
>
> **`[~]` not `[x]`, by design (at the time):** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend existed yet** (this was frontend-only work; see the deviation below).
>
> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`).
>
@@ -64,7 +69,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type).
- Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`)
> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4).
> **⚠️ HISTORY (2026-07-13).** The Stock Core backend exists and these screens call it as of 2026-07-17; the list endpoints assumed here (`GET /stock-transfers`, `/stock-adjustments`, `/stock-counts`, on-hand list) were all added for real. The in-memory Stock Core described below is deleted.
>
> **`[~]` not `[x]`, by design (at the time) — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4).
>
> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions).
>
@@ -83,6 +90,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-17 — connected to the real API (mock-data.ts deleted)
**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated.
**Transport + auth**
- Same-origin **Next `rewrites()` proxy** rather than backend CORS (see §0). The backend has no CORS and now needs none.
- Rebuilt `lib/api-client.ts` from `git show 0e4bcf1^`; added `credentials: "include"`.
- New `proxy.ts` route guard, `lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`. Login/logout are real.
- **Fixed the long-standing `app/login/page.tsx` resolver type error** — `lib/validations.ts` used `z.preprocess`, which widens the schema's *input* type to `unknown`, so `zodResolver` produced a `Resolver<{email: unknown}>` that could not satisfy `useForm<LoginValues>`. Form fields always yield strings (RHF defaults them to `""`), so the null-coercion it guarded against cannot happen. **`tsc --noEmit` is now fully clean** — the first time in this file's history.
**Two real bugs found by driving the browser (both fixed, both invisible to unit-level checks)**
1. **Logout didn't log you out.** AuthHex returns `user.userId: null` on login, so the Header could not supply the `userId` that `POST /auth/logout` required; the call was skipped and `erp_at` survived. Fixed backend-side (`userId` optional, resolved from the token claim, cookies always cleared). Verified: cookies now `[]` after logout.
2. **Generic error codes shadowed the server's message.** `errorMessage()` checked `CODE_MESSAGES[code]` before `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes (`conflict`/`not_found`/`validation_error`) now lose to `detail`.
**Contract drift reconciled** (types were rewritten field-by-field against the live OpenAPI, not assumed):
- `itemType``stockNature`; `ItemType` is now the Color/Size master. `variants.ts``item-types.ts`; the screen moved to `/dashboard/products/item-types`.
- `RfqComparison` was `{lines[].cells[]}` in this app but `{rows[].quotes[]}` on the server, and cells carry `quotationId`. `Rfq` has no `vendorIds`/`createdAt`; `StockTransfer`/`StockCount` had `createdBy`/`createdAt` the DTOs never returned (added server-side rather than dropping the columns); `ReasonCodeContext` had `"CountVariance"` where the server says `"Count"`; `EnterCounts` returns the whole `CountDto`, not `{lines}`; `PostCountResponse.adjustmentId` is nullable; `createReorderRequisition` returns a full `Requisition`, not `{qty}`.
- `remove()``updateStatus(id, "Inactive")` on brands/categories/item-types, each with a Status column and Deactivate/Activate (no `DELETE` exists — FR-MD-08).
- New `app/dashboard/products/categories/[id]` for subcategories (their own resource now); new `app/dashboard/products/settings` for Product Configuration (added the shadcn `switch` primitive via the CLI).
**Features deliberately removed rather than left lying**
- **`initialQty`** and the builder's Quantity column — no such field on the Item contract and no initial-receipt flow; stock arrives via a GRN. It never worked under the mock either.
- **GRN edit/delete** + the `grn/[id]/edit` route — the API has no `PUT`/`DELETE` for a GRN (FR-X-05).
- **RFQ "vendors invited"** — not persisted server-side. The screens show quotations received; the quote form offers any active vendor instead of "invited but pending".
- **Serial capture on GRN** — `CreateGrnLineInput` has no serial field despite FR-GRN-04 (priority M). Not collected rather than silently discarded. Flagged in `Backend/PROGRESS.md` + docs/11 §4.
**Fixed while rewiring:** the builder hardcoded `baseUomId: 1`, which only worked because the mock seeded that id — against a real DB it is a 422 or, worse, the wrong unit. It now adopts the first real UOM and says so when none exists. The per-row create loop still has no transaction, but the error now reports how many items landed before the failure instead of implying none did.
**Verified end-to-end in a real browser (Playwright), not just typechecked** — 17/17 then 9/9 on a recheck: guard redirect + `?next=` round-trip; login → cookies (`erp_at` httpOnly) → real user in Header; brand created via the UI; **duplicate → server 409 with its own message**; product-config screen reads the singleton; item-types shows the seeded Color/Size; logout clears cookies. Plus, through the page's own session: **cross-FK guard 422** (*"Subcategory 5 belongs to category 10, not 11"*), item created with **both** `categoryId` and `subCategoryId` + `brandId`, `conversions` present on the detail, **`CONFIG_DISABLED` 422** with the same item succeeding without the gated field and pre-existing items still readable, and a stale `If-Match`**412 `CONCURRENCY_CONFLICT`**. Test data was removed afterwards; the dev DB is back to empty masters.
> **The DB is near-empty and that is now visible.** The mock silently supplied warehouses, UOMs, reason codes and sample POs. Screens are blank until that data is created — correct behaviour, not a regression, but a dev seed would make the app pleasant to open. `lib/api/mock-data.ts`'s FIFO engine (`receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) is gone with it: **the browser no longer does inventory maths** — the server does.
>
> **Not yet exercised against real data:** GRN confirm → Stock Enquiry/Ledger/Valuation, transfers, counts and the wastage report. They compile and are wired, but proving the FIFO chain needs a warehouse + PO + receipt seeded first. That is the highest-value next verification.
### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes)
- Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface.
- Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers.
@@ -123,7 +164,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core.
- Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation).
- Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes.
- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged).
- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend existed at the time (`Backend/PROGRESS.md` §2 unchanged). **Superseded 2026-07-17** — the Procurement backend exists and these screens now call it; several assumed shapes turned out wrong (see the 2026-07-17 entry).
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server.
### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes)
+7
View File
@@ -0,0 +1,7 @@
# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts
# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and
# only ever calls this Next server at same-origin /api/v1.
#
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse.
BACKEND_ORIGIN=http://localhost:5224
+2
View File
@@ -32,6 +32,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# ...except the template, which carries no secrets and documents what to set.
!.env.local.example
# vercel
.vercel
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
setVendorId(rfqVendorId)
setLines(
rfq.lines.map((l): DraftLine => {
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
const cell = comparison.rows
.find((row) => row.itemId === l.itemId)
?.quotes.find((q) => q.vendorId === rfqVendorId)
return {
key: newKey(),
itemId: l.itemId,
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
const quotedVendorIds = useMemo(() => {
const set = new Set<number>()
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId)
return set
}, [comparison])
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
/**
* Vendors still available to quote.
*
* This used to be "invited but not yet quoted", but the invited list does not survive:
* `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor
* link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the
* comparison's columns come from who actually quoted rather than who was asked.
*/
const pendingVendors = useMemo(
() => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId),
[vendors, quotedVendorIds],
)
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -155,9 +166,9 @@ export default function RfqDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
<RfqStatusBadge status={rfq.status} />
</div>
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
<p className="text-base text-muted-foreground">
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
</p>
</div>
</div>
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
{comparison.lines.every((l) => l.cells.length === 0) ? (
{comparison.rows.every((r) => r.quotes.length === 0) ? (
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
) : (
<div className="overflow-x-auto">
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
{rfq.vendorIds.map((vid) => (
{/* Columns are the vendors that actually quoted — the server computes this. */}
{comparison.vendorIds.map((vid) => (
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{comparison.lines.map((line) => {
{comparison.rows.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
{rfq.vendorIds.map((vid) => {
const cell = line.cells.find((c) => c.vendorId === vid)
{comparison.vendorIds.map((vid) => {
const cell = line.quotes.find((c) => c.vendorId === vid)
return (
<TableCell key={vid} className="px-3 py-3.5">
{cell ? (
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
<Label className="text-base">Vendor</Label>
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select an invited vendor" />
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{pendingVendors.map((vid) => (
@@ -99,6 +99,12 @@ function NewRfqContent() {
setHeaderError(null)
setSubmitError(null)
// The server requires a requisition — an RFQ is always raised against one
// (docs/11 §3.2). Catch it here rather than letting the POST 400.
if (requisitionId === null) {
setHeaderError("Select the requisition this RFQ is raised against.")
return
}
if (vendorIds.size === 0) {
setHeaderError("Select at least one vendor to invite.")
return
@@ -5,10 +5,8 @@ import Link from "next/link"
import { FileText, Plus } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { RfqSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
useEffect(() => {
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([r, v]) => {
setRfqs(r.items)
setVendors(v.items)
})
rfqsApi
.list()
.then((r) => setRfqs(r.items))
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorNames(vendorIds: number[]) {
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
{/* "Vendors invited" is gone: the invite list is validated on create but not
persisted (docs/11 §3.2). Quotations received is the fact that survives. */}
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Quotations</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.quotationCount}</TableCell>
<TableCell className="px-3 py-3.5">
<RfqStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
setSubCategoryId(data.subCategoryId)
setBrandId(data.brandId)
setBaseUomId(data.baseUomId)
setDefaultVendorId(data.defaultVendorId)
setItemType(data.itemType)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "")
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
@@ -139,7 +145,7 @@ export default function ItemDetailPage() {
try {
const result = await itemsApi.update(
item.itemId,
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
{ sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null },
etag
)
applyItem(result.data)
@@ -389,8 +395,10 @@ export default function ItemDetailPage() {
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
{/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */}
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function BrandsPage() {
function load() {
setError(null)
brandsApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
@@ -87,10 +88,16 @@ export default function BrandsPage() {
setSubmitting(true)
try {
const brand = editing
? await brandsApi.update(editing.brandId, { name })
: await brandsApi.create({ name })
toast.success(editing ? "Brand updated" : "Brand created", brand.name)
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
} else {
result = await brandsApi.create({ name })
}
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +111,16 @@ export default function BrandsPage() {
}
}
async function handleDelete(brand: Brand) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setDeletingId(brand.brandId)
try {
await brandsApi.remove(brand.brandId)
toast.success("Brand deleted", brand.name)
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not delete brand", errorMessage(err))
toast.error("Could not update brand status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +215,7 @@ export default function BrandsPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,6 +225,9 @@ export default function BrandsPage() {
<TableRow key={b.brandId}>
<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>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
@@ -227,26 +240,32 @@ export default function BrandsPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${b.name}`}
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
<Trash2 className="size-4" />
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${b.name}?`}
description="This permanently removes the brand."
confirmLabel="Delete"
onConfirm={() => handleDelete(b)}
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</AlertDialog>
</div>
@@ -0,0 +1,233 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, 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"
import { Badge } from "@/components/ui/badge"
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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Subcategories of one category — the single optional level below it (FR-MD-04).
* The hierarchy is exactly two deep, so there is no recursion here by design.
*/
export default function CategorySubCategoriesPage() {
const params = useParams<{ id: string }>()
const categoryId = Number(params.id)
const [category, setCategory] = useState<Category | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<SubCategory | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
categoriesApi
.get(categoryId)
.then((res) => setCategory(res.data))
.catch((err) => setError(errorMessage(err)))
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => setSubCategories(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [categoryId])
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(sub: SubCategory) {
setEditing(sub)
setName(sub.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await subCategoriesApi.get(editing.subCategoryId)
await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "")
} else {
await categoriesApi.createSubCategory(categoryId, { name })
}
toast.success(editing ? "Subcategory updated" : "Subcategory created", name)
setOpen(false)
setName("")
setEditing(null)
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleToggleStatus(sub: SubCategory) {
const next = sub.status === "Active" ? "Inactive" : "Active"
setTogglingId(sub.subCategoryId)
try {
await subCategoriesApi.updateStatus(sub.subCategoryId, next)
toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground">
The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Subcategory</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit subcategory" : "New subcategory"}</DialogTitle>
<DialogDescription>Give the subcategory a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="sub-name">Name</FieldLabel>
<Input id="sub-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && subCategories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && subCategories !== null && subCategories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Network className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No subcategories yet items can attach straight to the category.</p>
</div>
)}
{!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<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>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
setSubmitting(true)
try {
const category = editing
? await categoriesApi.update(editing.categoryId, { name })
: await categoriesApi.create({ name })
toast.success(editing ? "Category updated" : "Category created", category.name)
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +110,16 @@ export default function CategoriesPage() {
}
}
async function handleDelete(category: Category) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.remove(category.categoryId)
toast.success("Category deleted", category.name)
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
<TableRow key={c.categoryId}>
<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>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree. */}
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
<Button
variant="ghost"
size="icon-sm"
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
<Trash2 className="size-4" />
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
@@ -2,15 +2,16 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName } from "@/lib/validations/master-data"
import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { VariantCategory } from "@/types/master-data"
import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -19,22 +20,30 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function VariantsPage() {
const [categories, setCategories] = useState<VariantCategory[] | null>(null)
/**
* Item Types (docs/11 §2.7) the dimension names (Color, Size, Material) the item
* builder's checkboxes read. Formerly "Variant Categories" in this app.
*
* These are names only. The values (Red, S, M) live in each item's generated SKU and are
* not stored, so nothing here links to an item renaming a type leaves existing SKUs
* untouched.
*/
export default function ItemTypesPage() {
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<VariantCategory | null>(null)
const [editing, setEditing] = useState<ItemType | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
variantCategoriesApi
.list()
.then((res) => setCategories(res.items))
itemTypesApi
.list({ pageSize: 200 })
.then((res) => setItemTypes(res.items))
.catch((err) => setError(errorMessage(err)))
}
@@ -47,24 +56,28 @@ export default function VariantsPage() {
setOpen(true)
}
function openEditDialog(category: VariantCategory) {
setEditing(category)
setName(category.name)
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateVariantCategoryName(name)
const nextErrors = validateItemTypeName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const category = editing
? await variantCategoriesApi.update(editing.variantCategoryId, { name })
: await variantCategoriesApi.create({ name })
toast.success(editing ? "Variant category updated" : "Variant category created", category.name)
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setEditing(null)
@@ -72,22 +85,24 @@ export default function VariantsPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete(category: VariantCategory) {
setDeletingId(category.variantCategoryId)
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(itemType: ItemType) {
const next = itemType.status === "Active" ? "Inactive" : "Active"
setTogglingId(itemType.itemTypeId)
try {
await variantCategoriesApi.remove(category.variantCategoryId)
toast.success("Variant category deleted", category.name)
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update status", errorMessage(err))
} finally {
setDeletingId(null)
setTogglingId(null)
}
}
@@ -99,23 +114,25 @@ export default function VariantsPage() {
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Category</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
<Input
id="variant-category-name"
id="item-type-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Material"
@@ -140,7 +157,7 @@ export default function VariantsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
{!error && itemTypes === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -148,37 +165,36 @@ export default function VariantsPage() {
</div>
)}
{!error && categories !== null && categories.length === 0 && (
{!error && itemTypes !== null && itemTypes.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No variant categories yet.</p>
<p className="text-base text-muted-foreground">No item types yet.</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
{!error && itemTypes !== null && itemTypes.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((c) => (
<TableRow key={c.variantCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{itemTypes.map((t) => (
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
<Pencil className="size-4" />
</Button>
@@ -188,20 +204,24 @@ export default function VariantsPage() {
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.variantCategoryId}
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
disabled={togglingId === t.itemTypeId}
/>
}
>
<Trash2 className="size-4" />
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the variant category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={t.status === "Active" ? "destructive" : "success"}
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
description={
t.status === "Active"
? "It disappears from the item builder. Existing items keep their SKUs — nothing references this record."
: "It reappears in the item builder."
}
confirmLabel={t.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(t)}
/>
</AlertDialog>
</div>
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, VariantCategory } from "@/types/master-data"
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
}
/**
* Colour is special-cased by name. This stays a frontend concern: item types are names
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
*/
function isColorCategory(categoryName: string): boolean {
return categoryName.trim().toLowerCase() === "color"
}
@@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string {
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
}
// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data).
const DEFAULT_BASE_UOM_ID = 1
export default function NewItemPage() {
const router = useRouter()
const [categories, setCategories] = useState<Category[] | null>(null)
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | null>(null)
const [brands, setBrands] = useState<Brand[] | null>(null)
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
/**
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
*/
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState<number[]>([])
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
const [quantities, setQuantities] = useState<Record<string, string>>({})
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
@@ -83,23 +94,40 @@ export default function NewItemPage() {
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
.then(([cat, br, vc]) => {
Promise.all([
categoriesApi.list({ pageSize: 200, status: "Active" }),
brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(),
uomsApi.list({ pageSize: 1 }),
])
.then(([cat, br, types, cfg, uoms]) => {
setCategories(cat.items)
setBrands(br.items)
setVariantCategories(vc.items)
setItemTypes(types.items)
setConfig(cfg)
setBaseUomId(uoms.items[0]?.uomId ?? null)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
const subCategoryOptions = useMemo(
() => (categories ?? []).filter((c) => c.parentId === categoryId),
[categories, categoryId]
)
const effectiveCategoryId = subCategoryId ?? categoryId
const effectiveCategoryLabel =
(categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? ""
// Subcategories are their own resource now — fetched per category rather than filtered
// out of a flat list by parentId (that column no longer exists).
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) {
setSubCategories([])
return
}
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategories(res.items))
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
function handleCategoryChange(value: number | null) {
@@ -107,28 +135,27 @@ export default function NewItemPage() {
setSubCategoryId(null)
}
function toggleVariantCategory(variantCategoryId: number) {
setCheckedVariantCategoryIds((prev) =>
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
function toggleItemType(itemTypeId: number) {
setCheckedItemTypeIds((prev) =>
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
)
setQuantities({})
}
async function handleAddVariantCategory() {
const nextErrors = validateVariantCategoryName(newCategoryName)
async function handleAddItemType() {
const nextErrors = validateItemTypeName(newCategoryName)
if (nextErrors.name) {
setNewCategoryError(nextErrors.name)
return
}
setAddingCategorySubmitting(true)
try {
const category = await variantCategoriesApi.create({ name: newCategoryName })
setVariantCategories((prev) => [...(prev ?? []), category])
setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId])
const created = await itemTypesApi.create({ name: newCategoryName })
setItemTypes((prev) => [...(prev ?? []), created.data])
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
setNewCategoryName("")
setNewCategoryError(null)
setAddingCategory(false)
toast.success("Variant category created", category.name)
toast.success("Item type created", created.data.name)
} catch (err) {
setNewCategoryError(errorMessage(err))
} finally {
@@ -136,34 +163,32 @@ export default function NewItemPage() {
}
}
function addValue(variantCategoryId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
function addValue(itemTypeId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
if (value) {
setValuesByCategory((prev) => {
const existing = prev[variantCategoryId] ?? []
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [variantCategoryId]: [...existing, value] }
return { ...prev, [itemTypeId]: [...existing, value] }
})
setQuantities({})
}
setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" }))
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(variantCategoryId: number, value: string) {
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
}))
setQuantities({})
}
const activeCategories = useMemo(
() =>
(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] }))
.filter((vc) => vc.values.length > 0),
[variantCategories, checkedVariantCategoryIds, valuesByCategory]
(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
.filter((t) => t.values.length > 0),
[itemTypes, checkedItemTypeIds, valuesByCategory]
)
const variants = useMemo(() => {
@@ -183,44 +208,58 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
}))
}, [activeCategories, effectiveCategoryLabel])
}, [activeCategories, effectiveLabel])
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
return
}
setSubmitting(true)
let created = 0
try {
let created = 0
for (const variant of variants) {
const qty = Number(quantities[variant.key] || 0)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
categoryId: effectiveCategoryId as number,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
categoryId: categoryId as number,
subCategoryId,
brandId,
baseUomId: DEFAULT_BASE_UOM_ID,
itemType: "Stocked",
baseUomId,
stockNature: "Stocked",
trackingMode: "None",
initialQty: Number.isFinite(qty) ? qty : 0,
})
created += 1
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
router.push("/dashboard/products")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create variants", errorMessage(err))
// Each row is its own POST with no transaction, so a failure partway (e.g. a
// duplicate SKU) leaves the earlier rows created. Say so rather than implying
// nothing happened.
const detail = errorMessage(err)
setSubmitError(
created > 0
? `${detail}${created} item${created === 1 ? "" : "s"} were already created before this failed.`
: detail,
)
toast.error("Could not create all variants", detail)
} finally {
setSubmitting(false)
}
}
const loading = !categories || !brands || !variantCategories
const loading = !categories || !brands || !itemTypes || !config
return (
<div className="flex flex-col gap-6">
@@ -230,7 +269,7 @@ export default function NewItemPage() {
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and variant categories (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
</div>
</div>
@@ -240,6 +279,16 @@ export default function NewItemPage() {
{loading && !loadError && <Skeleton className="h-64 w-full" />}
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM {" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
create one first
</Link>
.
</div>
)}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -250,7 +299,7 @@ export default function NewItemPage() {
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{topCategories.map((c) => (
{(categories ?? []).map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
@@ -259,54 +308,63 @@ export default function NewItemPage() {
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategoryOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Config flags are honoured by hiding the field: sending a gated value would
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>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategories.map((s) => (
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
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">Variants</h2>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the variant categories that apply, then add their values to generate a SKU per combination.
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{(variantCategories ?? []).map((vc) => (
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
<Checkbox
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
checked={checkedItemTypeIds.includes(t.itemTypeId)}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{vc.name}</span>
<span className="text-base font-medium">{t.name}</span>
</label>
))}
{!addingCategory && (
@@ -314,7 +372,7 @@ export default function NewItemPage() {
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another variant category"
aria-label="Add another item type"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
@@ -331,7 +389,7 @@ export default function NewItemPage() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddVariantCategory()
handleAddItemType()
}
}}
placeholder="Material"
@@ -339,7 +397,7 @@ export default function NewItemPage() {
aria-invalid={!!newCategoryError}
autoFocus
/>
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
<Plus className="size-4" />
Add
</Button>
@@ -363,38 +421,38 @@ export default function NewItemPage() {
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedVariantCategoryIds.length > 0 && (
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => {
const isColor = isColorCategory(vc.name)
const currentInput = inputByCategory[vc.variantCategoryId] ?? ""
const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? ""
{(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => {
const isColor = isColorCategory(t.name)
const currentInput = inputByCategory[t.itemTypeId] ?? ""
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
function addColor() {
const name = currentColorName.trim()
if (!name) return
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
}
return (
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
<Label className="text-base">{vc.name} values</Label>
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
<div className="flex gap-2">
{isColor ? (
<>
<input
type="color"
value={currentInput || "#EF4444"}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
aria-label="Pick color"
/>
<Input
value={currentColorName}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
@@ -408,24 +466,24 @@ export default function NewItemPage() {
) : (
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(vc.variantCategoryId)
addValue(t.itemTypeId)
}
}}
placeholder={vc.name}
placeholder={t.name}
className="h-11 text-base"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(vc.variantCategoryId))}>
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
<Plus className="size-4" />
Add {vc.name}
Add {t.name}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
const decoded = isColor ? decodeColorValue(v) : null
return (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
@@ -439,7 +497,7 @@ export default function NewItemPage() {
{decoded ? decoded.name : v}
<button
type="button"
onClick={() => removeValue(vc.variantCategoryId, v)}
onClick={() => removeValue(t.itemTypeId, v)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${decoded ? decoded.name : v}`}
>
@@ -461,10 +519,13 @@ export default function NewItemPage() {
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
{activeCategories.map((cat) => (
<TableHead key={cat.variantCategoryId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm text-indigo-700">Quantity</TableHead>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
</TableRow>
</TableHeader>
<TableBody>
@@ -488,16 +549,6 @@ export default function NewItemPage() {
)
})}
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
value={quantities[variant.key] ?? ""}
onChange={(e) => setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))}
placeholder="0"
className="h-9 w-24 text-sm"
/>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -505,6 +556,7 @@ export default function NewItemPage() {
</div>
)}
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
@@ -191,7 +191,7 @@ export default function ItemsPage() {
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
<TableCell className="px-3 py-3.5">{item.itemType}</TableCell>
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
@@ -0,0 +1,168 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Info } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
*
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the
* server (an item write carrying a gated field gets 422 CONFIG_DISABLED);
* `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend
* hiding the builder's type section IS the enforcement. That distinction is surfaced in
* the UI rather than hidden, because it changes what "off" actually guarantees.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<keyof ProductConfig | null>(null)
function load() {
setError(null)
productConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
[flag]: next,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
} finally {
setSaving(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground">
Switch optional product features on or off for this deployment (FR-MD-11).
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
<ToggleRow
label="Subcategories"
description="Adds one optional level below a category. Off ⇒ items attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
/>
<ToggleRow
label="Brands"
description="Items may carry a brand."
checked={config.brandsEnabled}
busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)}
/>
<ToggleRow
label="Item types"
description="The item builder offers Color / Size / Material dimensions when creating items."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything."
/>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
const LABELS: Record<string, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Item types",
}
function ToggleRow({
label,
description,
checked,
busy,
onChange,
note,
}: {
label: string
description: string
checked: boolean
busy: boolean
onChange: (next: boolean) => void
note?: string
}) {
return (
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0">
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{label}</span>
<span className="text-sm text-muted-foreground">{description}</span>
{note && (
<span className="mt-1 inline-flex items-start gap-1.5 text-sm text-amber-700">
<Info className="mt-0.5 size-4 shrink-0" />
{note}
</span>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
</div>
)
}
@@ -1,444 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn"
import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
binId: number | null
qty: string
unitCost: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `egline-${keySeq}`
}
function emptyLine(): DraftLine {
return {
key: newKey(),
poLineId: null,
itemId: null,
uomId: null,
binId: null,
qty: "",
unitCost: "",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
}
}
export default function EditGrnPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([])
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!Number.isFinite(grnId)) return
Promise.all([
grnsApi.get(grnId),
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
])
.then(([g, wh, it, uo]) => {
if (g.status !== "Draft") {
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
setGrn(g)
return
}
setGrn(g)
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setWarehouseId(g.warehouseId)
setLines(
g.lines.map(
(l): DraftLine => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: l.binId,
qty: String(l.qty),
unitCost: String(l.unitCost),
holdStatus: l.holdStatus,
batchNo: "",
expiryDate: "",
serialNumbersText: "",
})
)
)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [grnId])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key))
}
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
if (!grn) return
setSubmitError(null)
setHeaderError(null)
if (!warehouseId) {
setHeaderError("Select a warehouse.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const errors = validateLine({
itemId: line.itemId,
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
uomId: l.uomId as number,
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
}
})
setSubmitting(true)
try {
const updated = await grnsApi.update(grn.grnId, {
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN updated", `${updated.docNo} saved.`)
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not update GRN", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (loadError) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Edit GRN</h1>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
</div>
)
}
const loading = !grn || !warehouses || !items || !uoms
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {grn?.docNo ?? "GRN"}</h1>
<p className="text-base text-muted-foreground">Only Draft GRNs can be edited confirming posts stock layers permanently.</p>
</div>
</div>
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<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>
{lines.length > 0 && (
<Table className="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-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 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">
<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">
<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]} />
</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>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link
href={`/dashboard/receiving/grn/${grnId}`}
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Save changes"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -50,7 +50,7 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!grn) return
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
}, [grn?.warehouseId])
function itemFor(itemId: number) {
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
function switchMode(next: Mode) {
@@ -2,14 +2,13 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,7 +22,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -40,7 +38,6 @@ export default function GrnListPage() {
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
// Debounce the search box so typing doesn't refetch on every keystroke.
useEffect(() => {
@@ -71,19 +68,6 @@ export default function GrnListPage() {
useEffect(load, [page, query, status])
async function handleDelete(grn: GrnSummary) {
setDeletingId(grn.grnId)
try {
await grnsApi.remove(grn.grnId)
toast.success("GRN deleted", `${grn.docNo} has been removed.`)
load()
} catch (err) {
toast.error("Could not delete GRN", errorMessage(err))
} finally {
setDeletingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
return (
@@ -196,7 +180,6 @@ export default function GrnListPage() {
</TableHeader>
<TableBody>
{grns.map((grn) => {
const isDraft = grn.status === "Draft"
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
@@ -224,47 +207,9 @@ export default function GrnListPage() {
<Eye className="size-4" />
</Link>
{isDraft ? (
<Link
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${grn.docNo}`}
>
<Pencil className="size-4" />
</Link>
) : (
<Button
variant="ghost"
size="icon-sm"
disabled
aria-label={`Edit ${grn.docNo} (not editable once ${grn.status.toLowerCase()})`}
>
<Pencil className="size-4" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${grn.docNo}`}
disabled={!isDraft || deletingId === grn.grnId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${grn.docNo}?`}
description="This permanently removes the draft GRN. It has not been confirmed, so no stock layers or ledger entries exist yet."
confirmLabel="Delete"
onConfirm={() => handleDelete(grn)}
/>
</AlertDialog>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
useEffect(() => {
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([r, it, wh]) => {
setRows(r)
setRows(r.items)
setItems(it.items)
setWarehouses(wh.items)
})
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
const key = `${alert.itemId}-${alert.warehouseId}`
setRequesting(key)
try {
// The server returns the full requisition; the suggested qty is on its line.
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
setRequested((prev) => new Set(prev).add(key))
toast.success("Requisition created", `${res.docNo} for ${res.qty} units.`)
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
setSrcBins([])
return
}
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
}, [srcWarehouseId])
useEffect(() => {
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
setDestBins([])
return
}
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
}, [destWarehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
import { wastageApi, wastageReasonCodeIds } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setWarehouses(wh.items)
setItems(it.items)
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
async function handleSubmit() {
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
import { wastageApi, wastageReasonCodeIds, WastageRecord } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -33,8 +33,7 @@ export default function WastagePage() {
useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([rc, it, wh]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
setItems(it.items)
setWarehouses(wh.items)
})
@@ -40,7 +40,7 @@ export default function WarehouseDetailPage() {
const [submitting, setSubmitting] = useState(false)
function loadBins() {
warehousesApi.listBins(warehouseId).then((res) => setBins(res.items)).catch((err) => setError(errorMessage(err)))
warehousesApi.listBins(warehouseId).then(setBins).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
@@ -41,7 +41,7 @@ export default function WarehousesPage() {
.then(async (res) => {
setWarehouses(res.items)
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
setBins(allBins.flatMap((b) => b.items))
setBins(allBins.flat())
})
.catch((err) => setError(errorMessage(err)))
}
+23 -3
View File
@@ -3,11 +3,14 @@
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Eye, EyeOff } from "lucide-react"
import { loginSchema, LoginValues } from "@/lib/validations"
import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -39,8 +42,10 @@ function GoogleIcon() {
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
@@ -48,8 +53,18 @@ export default function LoginPage() {
})
const onSubmit = form.handleSubmit(async (values) => {
console.log(values)
router.push("/dashboard")
setSubmitError(null)
try {
// The session arrives as httpOnly cookies; the body carries only the user profile.
const session = await authApi.login({ identifier: values.email, password: values.password })
setStoredUser(session.user)
// Honour the path the guard bounced us from, but only same-origin paths — an
// attacker-supplied absolute URL here would be an open redirect.
const next = searchParams.get("next")
router.push(next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard")
} catch (err) {
setSubmitError(errorMessage(err))
}
})
return (
@@ -79,6 +94,11 @@ export default function LoginPage() {
</div>
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<FieldGroup className="space-y-5">
<Field data-invalid={!!form.formState.errors.email}>
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
@@ -14,8 +14,10 @@ import {
Menu,
Package,
PackageCheck,
Ruler,
Settings,
ShoppingCart,
SlidersHorizontal,
SwatchBook,
Tag,
Truck,
@@ -43,7 +45,9 @@ const navItems: {
{ title: "Item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook },
{ title: "Item Type", href: "/dashboard/products/item-types", icon: SwatchBook },
{ title: "UOM", href: "/dashboard/products/uoms", icon: Ruler },
{ title: "Configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
],
},
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
@@ -1,10 +1,13 @@
"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"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
import { AuthUser } from "@/types/auth"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@@ -17,6 +20,14 @@ import {
} from "@/components/ui/dropdown-menu"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
/** Avatar initials from a display name: "ERP Admin" -> "EA", "erpadmin" -> "ER". */
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return "?"
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
const PROCUREMENT_TITLES: Record<string, string> = {
"/dashboard/procurement": "Procurement",
"/dashboard/procurement/requisitions": "Requisitions",
@@ -69,6 +80,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
@@ -122,9 +137,31 @@ 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, setUser] = useState<AuthUser | null>(null)
useEffect(() => setUser(getStoredUser()), [])
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
async function handleLogout() {
try {
// Always call it, even without a userId: AuthHex returns none on login, so the
// server resolves the user from the session token. Skipping the call would leave
// the erp_at cookie alive and the "logout" would be cosmetic.
await authApi.logout(user?.userId ?? null)
} catch {
// A failed logout must not strand the user in the app: the cookie may already be
// gone or upstream may be down. Clear locally and leave either way — worst case the
// server-side session lapses on its own.
} finally {
clearStoredUser()
router.push("/login")
router.refresh()
}
}
return (
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
<div className="flex items-center gap-3">
@@ -212,17 +249,17 @@ export function Header() {
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
<Avatar>
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
JM
{initials(displayName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
John Martinez
{displayName(user)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-slate-900">John Martinez</p>
<p className="text-sm font-normal text-muted-foreground">john52martinez@gmail.com</p>
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -242,7 +279,7 @@ export function Header() {
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
render={<Link href="/login" />}
onClick={handleLogout}
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
>
<LogOut />
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+109
View File
@@ -0,0 +1,109 @@
// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1). Per-endpoint
// methods live in lib/api/*.ts — no scattered fetch() in components.
//
// Transport: the API is reached SAME-ORIGIN through the Next rewrite in next.config.ts
// (/api/* -> BACKEND_ORIGIN). That is why API_BASE is relative and why no CORS setup
// exists on the backend: there is no cross-origin request to allow.
//
// Auth: the session is an httpOnly `erp_at` cookie issued by POST /auth/login (docs/11
// §2.0) — there is no bearer token to read, and by design JS cannot read the cookie.
// `credentials: "include"` is what actually authenticates every call.
import { ApiResult, ProblemDetails } from "@/types/common"
const API_BASE = "/api/v1"
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
export class ApiError extends Error {
status: number
code?: string
detail?: string
errors?: Record<string, string[]>
traceId?: string
constructor(problem: ProblemDetails) {
super(problem.title || "Request failed")
this.status = problem.status
this.code = problem.code
this.detail = problem.detail
this.errors = problem.errors
this.traceId = problem.traceId
}
}
export interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown
/** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). Echo the ETag verbatim, quotes included. */
ifMatch?: string
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
idempotencyKey?: string
/** Sent as X-XSRF-TOKEN. Only the [ValidateCsrf] actions on AuthController need it (docs/11 §2.0). */
csrf?: boolean
}
/** Reads the non-httpOnly CSRF cookie. It rotates on every session write, so read it per call. */
export function readCsrfToken(): string | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
const finalHeaders: Record<string, string> = {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(ifMatch ? { "If-Match": ifMatch } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
...((headers as Record<string, string> | undefined) ?? {}),
}
const response = await fetch(`${API_BASE}${path}`, {
...rest,
credentials: "include", // sends erp_at; the whole auth story depends on this
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
let problem: ProblemDetails
try {
problem = (await response.json()) as ProblemDetails
} catch {
// e.g. a proxy/network failure with a non-JSON body.
problem = { title: response.statusText || "Request failed", status: response.status }
}
if (!problem.status) problem.status = response.status
throw new ApiError(problem)
}
return response
}
/** Fire a request and decode the JSON body only (no ETag needed). */
export async function apiRequest<T>(path: string, options?: RequestOptions): Promise<T> {
const response = await rawRequest(path, options)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
/** Fire a request and also surface the ETag header, for resources that support If-Match. */
export async function apiRequestWithETag<T>(path: string, options?: RequestOptions): Promise<ApiResult<T>> {
const response = await rawRequest(path, options)
const etag = response.headers.get("ETag")
const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T)
return { data, etag }
}
/** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */
export function buildQuery(params: object): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) {
if (value === null || value === undefined || value === "") continue
search.set(key, String(value))
}
const qs = search.toString()
return qs ? `?${qs}` : ""
}
+32
View File
@@ -0,0 +1,32 @@
// Auth endpoints (docs/11-BACKEND-PHASE1.md §2.0). ERPCore proxies the AuthHex IdP and
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
import { apiRequest } from "@/lib/api-client"
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
export const authApi = {
/** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */
login(request: LoginRequest): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/login", { method: "POST", body: request })
},
/** Also issues a session, same as login. */
register(request: RegisterRequest): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/register", { method: "POST", body: request })
},
/**
* Clears all three cookies server-side.
*
* `userId` is optional and normally null: AuthHex omits it from its own login response,
* so the browser never learns it. The server falls back to the session token's UserId
* claim, and clears the cookies regardless of what the upstream revoke does.
*/
logout(userId: string | null = null): Promise<void> {
return apiRequest<void>("/auth/logout", { method: "POST", body: { userId } })
},
/** Exchanges the path-scoped erp_rt cookie for a fresh session. */
refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
},
}
+21 -46
View File
@@ -1,64 +1,39 @@
// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
// One typed client method per Brand endpoint (docs/11-BACKEND-PHASE1.md §2.6; FR-MD-09).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data"
export interface ListBrandsParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
status?: EntityStatus
/** `name` / `-name` etc. (docs/11 §1.5). */
sort?: string
}
export const brandsApi = {
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockBrands
.filter((b) => !term || b.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<Brand>>(`/brands${buildQuery(params)}`)
},
create(request: CreateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
if (mockBrands.some((b) => b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
const brand: Brand = { brandId: allocateBrandId(), name, createdAt: new Date().toISOString() }
mockBrands.push(brand)
return mockDelay(brand)
get(brandId: number): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>(`/brands/${brandId}`)
},
update(brandId: number, request: UpdateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
const brand = mockBrands.find((b) => b.brandId === brandId)
if (!brand) return Promise.reject(new Error("Brand not found."))
if (mockBrands.some((b) => b.brandId !== brandId && b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
brand.name = name
return mockDelay(brand)
create(request: CreateBrandRequest): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>("/brands", { method: "POST", body: request })
},
remove(brandId: number): Promise<void> {
const index = mockBrands.findIndex((b) => b.brandId === brandId)
if (index === -1) return Promise.reject(new Error("Brand not found."))
mockBrands.splice(index, 1)
return mockDelay(undefined)
update(brandId: number, request: UpdateBrandRequest, ifMatch: string): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>(`/brands/${brandId}`, { method: "PUT", body: request, ifMatch })
},
/**
* Deactivate/reactivate. There is no DELETE anywhere in the API: masters referenced by
* transactions are deactivated, never removed (FR-MD-08).
*/
updateStatus(brandId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/brands/${brandId}/status`, { method: "PATCH", body: { status } })
},
}
+64 -46
View File
@@ -1,62 +1,80 @@
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { Category, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/master-data"
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
// One typed client method per Category / SubCategory endpoint
// (docs/11-BACKEND-PHASE1.md §2.3; FR-MD-04).
//
// The hierarchy is exactly two levels. Categories no longer self-nest — `parentId` and
// `GET /categories?tree=true` were removed on 2026-07-16 — so there is no tree() here.
// Subcategories are listed/created under their parent; updates address them by their own id.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
Category,
CreateCategoryRequest,
CreateSubCategoryRequest,
SubCategory,
UpdateCategoryRequest,
UpdateSubCategoryRequest,
} from "@/types/master-data"
export interface ListCategoriesParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
status?: EntityStatus
sort?: string
}
export type ListSubCategoriesParams = ListCategoriesParams
export const categoriesApi = {
list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockCategories
.filter((c) => !term || c.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
return apiRequest<PagedResponse<Category>>(`/categories${buildQuery(params)}`)
},
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
get(categoryId: number): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>(`/categories/${categoryId}`)
},
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
create(request: CreateCategoryRequest): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>("/categories", { method: "POST", body: request })
},
update(categoryId: number, request: UpdateCategoryRequest, ifMatch: string): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>(`/categories/${categoryId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(categoryId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/categories/${categoryId}/status`, { method: "PATCH", body: { status } })
},
/** 404s if the parent category does not exist. */
listSubCategories(categoryId: number, params: ListSubCategoriesParams = {}): Promise<PagedResponse<SubCategory>> {
return apiRequest<PagedResponse<SubCategory>>(`/categories/${categoryId}/subcategories${buildQuery(params)}`)
},
createSubCategory(categoryId: number, request: CreateSubCategoryRequest): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/categories/${categoryId}/subcategories`, {
method: "POST",
body: request,
})
},
}
export const subCategoriesApi = {
get(subCategoryId: number): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/subcategories/${subCategoryId}`)
},
/** Name only — a subcategory cannot be moved to another category (docs/11 §2.3). */
update(subCategoryId: number, request: UpdateSubCategoryRequest, ifMatch: string): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/subcategories/${subCategoryId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
create(request: CreateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const parentId = request.parentId ?? null
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
return Promise.reject(new Error("Selected parent category does not exist."))
}
const category: Category = { categoryId: allocateCategoryId(), name, parentId, createdAt: new Date().toISOString() }
mockCategories.push(category)
return mockDelay(category)
},
update(categoryId: number, request: UpdateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockCategories.find((c) => c.categoryId === categoryId)
if (!category) return Promise.reject(new Error("Category not found."))
category.name = name
return mockDelay(category)
},
remove(categoryId: number): Promise<void> {
const index = mockCategories.findIndex((c) => c.categoryId === categoryId)
if (index === -1) return Promise.reject(new Error("Category not found."))
mockCategories.splice(index, 1)
return mockDelay(undefined)
updateStatus(subCategoryId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/subcategories/${subCategoryId}/status`, { method: "PATCH", body: { status } })
},
}
+24 -186
View File
@@ -1,223 +1,61 @@
// One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4).
// In-memory mock store (lib/api/mock-data.ts) — no backend API calls. Note
// GET /grns and GET /grns/{id} are not in docs/11-BACKEND-PHASE1.md §4 — see
// the note in Frontend/PROGRESS.md §4.
//
// `confirm` is the transactional one: the SERVER creates the FIFO layers, posts the
// inbound ledger and accrues PO receipts (FR-GRN-06). This client only triggers it and
// renders the returned side effects — the browser no longer does inventory maths.
//
// There is deliberately no update()/remove(): the API has no PUT or DELETE for a GRN.
// A confirmed receipt is corrected with a reversing document, never edited (FR-X-05).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ConfirmGrnResponse,
CreateGrnRequest,
CreatedLayer,
Grn,
GrnStatus,
GrnSummary,
ReleaseAction,
ReleaseGrnLineResponse,
} from "@/types/grn"
import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data"
export interface ListGrnsParams {
page?: number
pageSize?: number
/** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */
q?: string
status?: GrnStatus
poId?: number
vendorId?: number
warehouseId?: number
}
function toSummary(grn: Grn): GrnSummary {
return {
grnId: grn.grnId,
docNo: grn.docNo,
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: grn.warehouseId,
status: grn.status,
createdAt: grn.createdAt,
}
sort?: string
}
export const grnsApi = {
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockGrns
.filter((g) => !params.status || g.status === params.status)
.filter((g) => !params.poId || g.poId === params.poId)
.filter((g) => !params.warehouseId || g.warehouseId === params.warehouseId)
.filter((g) => {
if (!term) return true
const haystack = [g.docNo, String(g.poId ?? ""), String(g.vendorId), String(g.warehouseId)]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
.map(toSummary)
.sort((a, b) => b.grnId - a.grnId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
},
get(grnId: number): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
return mockDelay(grn)
return apiRequest<Grn>(`/grns/${grnId}`)
},
/** 422 OVER_RECEIPT_TOLERANCE if qty exceeds the PO's open qty beyond tolerance. */
create(request: CreateGrnRequest): Promise<Grn> {
const grnId = allocateGrnId()
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
const grn: Grn = {
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: request.poId ?? null,
// Vendor is derived from the PO when receiving against one (as the real
// backend does) — request.vendorId is only meaningful for a direct receipt.
vendorId: referencedPo?.vendorId ?? request.vendorId ?? 0,
warehouseId: request.warehouseId,
status: "Draft",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
})),
}
mockGrns.push(grn)
return mockDelay(grn)
return apiRequest<Grn>("/grns", { method: "POST", 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.
*/
confirm(grnId: number, idempotencyKey?: string): Promise<ConfirmGrnResponse> {
void idempotencyKey // real backend dedupes on this; the mock always reprocesses
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status === "Confirmed" || grn.status === "Closed") {
return Promise.reject(new Error(`${grn.docNo} has already been confirmed.`))
}
grn.status = "Confirmed"
const createdLayers: CreatedLayer[] = []
const ledgerRefs: number[] = []
for (const line of grn.lines) {
// FR-GRN-06: each line creates a FIFO layer + posts an inbound ledger entry.
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: grn.warehouseId,
binId: line.binId,
batchId: line.batchId,
grnLineId: line.grnLineId,
qty: line.qty,
unitCost: line.unitCost,
userId: grn.createdBy,
sourceDocType: "GRN",
sourceDocId: grn.grnId,
})
createdLayers.push({
layerId: layer.layerId,
itemId: layer.itemId,
warehouseId: layer.warehouseId,
batchId: layer.batchId,
qtyReceived: layer.qtyReceived,
qtyRemaining: layer.qtyRemaining,
unitCost: layer.unitCost,
receiptDate: layer.receiptDate,
})
ledgerRefs.push(ledger.ledgerId)
// FR-PROC-07: accrue the PO line's received quantity as GRNs confirm.
if (line.poLineId && grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
const poLine = po?.lines.find((l) => l.poLineId === line.poLineId)
if (poLine) poLine.qtyReceived = Math.min(poLine.qty, poLine.qtyReceived + line.qty)
}
}
let poStatus: string | null = null
if (grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
if (po) {
const fullyReceived = po.lines.every((l) => l.qtyReceived >= l.qty)
const anyReceived = po.lines.some((l) => l.qtyReceived > 0)
po.status = fullyReceived ? "FullyReceived" : anyReceived ? "PartiallyReceived" : po.status
poStatus = po.status
}
}
const response: ConfirmGrnResponse = {
grnId: grn.grnId,
status: grn.status,
postedAt: new Date().toISOString(),
createdLayers,
ledgerRefs,
poStatus,
}
return mockDelay(response)
return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, { method: "POST", idempotencyKey })
},
/** "Release" makes an on-hold line issuable; "Reject" routes it to a purchase return. */
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
const grn = mockGrns.find((g) => g.grnId === grnId)
const line = grn?.lines.find((l) => l.grnLineId === grnLineId)
if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`))
line.holdStatus = action === "Release" ? "Available" : "Rejected"
return mockDelay({ grnLineId: line.grnLineId, holdStatus: line.holdStatus })
},
// Draft-only — once confirmed, a GRN has created stock layers/ledger entries
// and is no longer safe to rewrite in place.
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be edited.`))
}
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
grn.poId = request.poId ?? null
grn.vendorId = referencedPo?.vendorId ?? request.vendorId ?? grn.vendorId
grn.warehouseId = request.warehouseId
grn.lines = request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
}))
return mockDelay(grn)
},
remove(grnId: number): Promise<void> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be deleted.`))
}
mockGrns.splice(mockGrns.indexOf(grn), 1)
return mockDelay(undefined)
return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
method: "POST",
body: { action },
})
},
}
+41
View File
@@ -0,0 +1,41 @@
// One typed client method per Item Type endpoint (docs/11-BACKEND-PHASE1.md §2.7; FR-MD-10).
//
// Formerly `variants.ts` / `variantCategoriesApi`. An item type is a dimension NAME
// (Color, Size, Material) and nothing more: no item references one, and there is no value
// resource. `list()` exists to populate the item builder's dropdown — the chosen values are
// encoded into the client-generated SKU and never stored (docs/10 Part C.9).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { CreateItemTypeRequest, ItemType, UpdateItemTypeRequest } from "@/types/master-data"
export interface ListItemTypesParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
sort?: string
}
export const itemTypesApi = {
list(params: ListItemTypesParams = {}): Promise<PagedResponse<ItemType>> {
return apiRequest<PagedResponse<ItemType>>(`/item-types${buildQuery(params)}`)
},
get(itemTypeId: number): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>(`/item-types/${itemTypeId}`)
},
create(request: CreateItemTypeRequest): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>("/item-types", { method: "POST", body: request })
},
/** Renaming does not touch existing items — their SKUs already encode the old value. */
update(itemTypeId: number, request: UpdateItemTypeRequest, ifMatch: string): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>(`/item-types/${itemTypeId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(itemTypeId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/item-types/${itemTypeId}/status`, { method: "PATCH", body: { status } })
},
}
+26 -122
View File
@@ -1,25 +1,18 @@
// One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08).
// `list` also backs the GRN/PO/Requisition/RFQ item pickers built in earlier sessions.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// `list` also backs the GRN/PO/Requisition/RFQ item pickers.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
Item,
ItemListItem,
ItemReorderSetting,
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
UpdateUomConversionsRequest,
UpdateUomConversionsResponse,
} from "@/types/master-data"
import {
allocateItemId,
bumpItemVersion,
getItemVersion,
initItemVersion,
mockDelay,
mockItems,
} from "@/lib/api/mock-data"
export interface ListItemsParams {
page?: number
@@ -27,139 +20,50 @@ export interface ListItemsParams {
q?: string
status?: EntityStatus
categoryId?: number
subCategoryId?: number
brandId?: number
trackingMode?: TrackingMode
}
function toListItem(item: Item): ItemListItem {
return {
itemId: item.itemId,
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? null,
baseUomId: item.baseUomId,
defaultVendorId: item.defaultVendorId,
itemType: item.itemType,
trackingMode: item.trackingMode,
taxClass: item.taxClass,
status: item.status,
}
}
function skuTaken(sku: string, excludeItemId?: number) {
return mockItems.some((i) => i.itemId !== excludeItemId && i.sku.toLowerCase() === sku.toLowerCase())
sort?: string
}
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockItems
.filter((i) => !params.status || i.status === params.status)
.filter((i) => !params.categoryId || i.categoryId === params.categoryId)
.filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode)
.filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term))
.sort((a, b) => a.sku.localeCompare(b.sku))
.map(toListItem)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
},
get(itemId: number): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
return mockDelay({ data: item, etag: String(getItemVersion(itemId)) })
return apiRequestWithETag<Item>(`/items/${itemId}`)
},
/**
* 400 SKU_DUPLICATE if the SKU exists; 422 CONFIG_DISABLED if subCategoryId/brandId is
* sent while that feature is switched off; 422 if the subcategory belongs to a
* different category.
*/
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const item: Item = {
itemId: allocateItemId(),
sku,
name: request.name.trim(),
description: request.description?.trim() || null,
categoryId: request.categoryId,
brandId: request.brandId ?? null,
baseUomId: request.baseUomId,
defaultVendorId: request.defaultVendorId ?? null,
itemType: request.itemType,
trackingMode: request.trackingMode,
taxClass: request.taxClass?.trim() || null,
status: "Active",
reorder: [],
conversions: [],
initialQty: request.initialQty ?? null,
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockItems.push(item)
initItemVersion(item.itemId)
return mockDelay({ data: item, etag: "1" })
return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
},
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
if (String(getItemVersion(itemId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku, itemId)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
item.sku = sku
item.name = request.name.trim()
item.description = request.description?.trim() || null
item.categoryId = request.categoryId
item.brandId = request.brandId ?? null
item.baseUomId = request.baseUomId
item.defaultVendorId = request.defaultVendorId ?? null
item.itemType = request.itemType
item.trackingMode = request.trackingMode
item.taxClass = request.taxClass?.trim() || null
item.updatedAt = new Date().toISOString()
const next = bumpItemVersion(itemId)
return mockDelay({ data: item, etag: String(next) })
return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.status = status
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay(undefined)
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.reorder = request.settings
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ settings: item.reorder })
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
method: "PUT",
body: request,
})
},
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor }))
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions })
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
method: "PUT",
body: request,
})
},
}
-833
View File
@@ -1,833 +0,0 @@
// In-memory sample data backing every lib/api/*.ts module — the app has no
// fetch-based backend connection (lib/api-client.ts and lib/auth-token.ts were
// removed). Shapes mirror docs/11-BACKEND-PHASE1.md.
import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data"
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
import { Grn } from "@/types/grn"
import {
AdjustmentStatus,
CountStatus,
CountType,
LedgerDirection,
LedgerEntry,
ReasonCode,
TransferStatus,
} from "@/types/stock"
export const mockWarehouses: Warehouse[] = [
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
]
export const mockBins: Bin[] = [
{ binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" },
{ binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" },
{ binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" },
{ binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" },
{ binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" },
]
let nextWarehouseId = 3
let nextBinId = 6
export function allocateWarehouseId() {
return nextWarehouseId++
}
export function allocateBinId() {
return nextBinId++
}
export const mockUoms: Uom[] = [
{ uomId: 1, name: "EA" },
{ uomId: 2, name: "Box-12" },
{ uomId: 3, name: "KG" },
]
let nextUomId = 4
export function allocateUomId() {
return nextUomId++
}
export const mockCategories: Category[] = [
{ categoryId: 3, name: "Hardware", parentId: null, createdAt: "2026-06-01T08:00:00Z" },
{ categoryId: 12, name: "Fasteners", parentId: 3, createdAt: "2026-06-01T08:05:00Z" },
{ categoryId: 20, name: "Power Tools", parentId: null, createdAt: "2026-06-02T09:00:00Z" },
]
let nextCategoryId = 21
export function allocateCategoryId() {
return nextCategoryId++
}
export const mockBrands: Brand[] = [
{ brandId: 1, name: "Bosch", createdAt: "2026-06-01T08:00:00Z" },
{ brandId: 2, name: "Makita", createdAt: "2026-06-02T09:00:00Z" },
]
let nextBrandId = 3
export function allocateBrandId() {
return nextBrandId++
}
export const mockVariantCategories: VariantCategory[] = [
{ variantCategoryId: 1, name: "Color", createdAt: "2026-06-01T08:00:00Z" },
{ variantCategoryId: 2, name: "Size", createdAt: "2026-06-01T08:00:00Z" },
]
let nextVariantCategoryId = 3
export function allocateVariantCategoryId() {
return nextVariantCategoryId++
}
export const mockVendors: Vendor[] = [
{
vendorId: 5,
code: "VN-005",
name: "Lanka Steel Traders (Pvt) Ltd",
terms: "NET30",
taxReg: "134567890-7000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
vendorId: 8,
code: "VN-008",
name: "Ceylon Hardware Supplies",
terms: "NET45",
taxReg: "198765432-1000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-05T08:00:00Z",
updatedAt: null,
},
]
// A handful more so the vendors list has something real to paginate/search through.
const extraVendorNames = [
"Colombo Timber & Plywood Co.",
"Kandy Electrical Distributors",
"Galle Packaging Solutions",
"Jaffna Agro Supplies",
"Negombo Fasteners (Pvt) Ltd",
"Kurunegala Paints & Coatings",
"Trinco Marine Hardware",
"Ratnapura Gems & Tools",
]
for (let i = 0; i < extraVendorNames.length; i++) {
const vendorId = 9 + i
mockVendors.push({
vendorId,
code: `VN-${String(vendorId).padStart(3, "0")}`,
name: extraVendorNames[i],
terms: i % 2 === 0 ? "NET30" : "NET60",
taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`,
currency: "LKR",
status: i % 5 === 0 ? "Inactive" : "Active",
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
updatedAt: null,
})
}
let nextVendorId = 9 + extraVendorNames.length
export function allocateVendorId() {
return nextVendorId++
}
// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion
// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no
// version field of its own (it travels as an HTTP ETag header, not a body field).
const mockVendorVersions = new Map<number, number>(mockVendors.map((v) => [v.vendorId, 1]))
export function getVendorVersion(vendorId: number): number {
return mockVendorVersions.get(vendorId) ?? 1
}
export function bumpVendorVersion(vendorId: number): number {
const next = getVendorVersion(vendorId) + 1
mockVendorVersions.set(vendorId, next)
return next
}
export function initVendorVersion(vendorId: number) {
mockVendorVersions.set(vendorId, 1)
}
// Full Item records (docs/11 §2.1). ItemListItem (the list/GRN-picker view) is
// derived from these in lib/api/items.ts, same "full record → mapped summary"
// pattern as mockPurchaseOrders → PurchaseOrderSummary.
export const mockItems: Item[] = [
{
itemId: 1001,
sku: "ITM-1001",
name: "Steel Bolt M8x40",
description: "Grade 8.8 zinc-plated hex bolt",
categoryId: 12,
baseUomId: 1,
defaultVendorId: 5,
itemType: "Stocked",
trackingMode: "Batch",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }],
conversions: [{ conversionId: 33, fromUom: 2, toUom: 1, factor: 12 }],
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
itemId: 1002,
sku: "ITM-1002",
name: "Steel Nut M8",
description: "Grade 8 zinc-plated hex nut",
categoryId: 12,
baseUomId: 1,
defaultVendorId: 5,
itemType: "Stocked",
trackingMode: "None",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }],
conversions: [],
createdAt: "2026-06-01T08:05:00Z",
updatedAt: null,
},
{
itemId: 1003,
sku: "ITM-1003",
name: "Cordless Drill 18V",
description: "18V lithium-ion cordless drill/driver, includes charger",
categoryId: 20,
baseUomId: 1,
defaultVendorId: 8,
itemType: "Stocked",
trackingMode: "Serial",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 2, reorderPoint: 15, reorderQty: 20 }],
conversions: [],
createdAt: "2026-06-05T08:10:00Z",
updatedAt: null,
},
]
let nextItemId = 1004
export function allocateItemId() {
return nextItemId++
}
// Concurrency token per item (same out-of-band ETag pattern as mockVendorVersions).
const mockItemVersions = new Map<number, number>(mockItems.map((i) => [i.itemId, 1]))
export function getItemVersion(itemId: number): number {
return mockItemVersions.get(itemId) ?? 1
}
export function bumpItemVersion(itemId: number): number {
const next = getItemVersion(itemId) + 1
mockItemVersions.set(itemId, next)
return next
}
export function initItemVersion(itemId: number) {
mockItemVersions.set(itemId, 1)
}
export const mockPurchaseOrders: PurchaseOrder[] = [
{
poId: 342,
docNo: "PO-2026-00342",
vendorId: 5,
requisitionId: 210,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-07T09:40:00Z",
updatedAt: null,
totals: { subTotal: 112100.0, tax: 20178.0, grandTotal: 132278.0, currency: "LKR" },
lines: [
{ poLineId: 900, itemId: 1001, uomId: 1, warehouseId: 1, qty: 5000, unitPrice: 12.5, tax: 0.18, qtyReceived: 0 },
{ poLineId: 901, itemId: 1002, uomId: 1, warehouseId: 1, qty: 8000, unitPrice: 6.2, tax: 0.18, qtyReceived: 0 },
],
},
{
poId: 350,
docNo: "PO-2026-00350",
vendorId: 8,
requisitionId: null,
status: "PartiallyReceived",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-09T09:00:00Z",
updatedAt: "2026-07-10T11:00:00Z",
totals: { subTotal: 22500.0, tax: 4050.0, grandTotal: 26550.0, currency: "LKR" },
lines: [
{ poLineId: 910, itemId: 1003, uomId: 1, warehouseId: 2, qty: 50, unitPrice: 450.0, tax: 0.18, qtyReceived: 20 },
],
},
]
let nextPoId = 351
export function allocatePoId() {
return nextPoId++
}
// Concurrency token per PO (same out-of-band ETag pattern as mockVendorVersions,
// docs/11 §1.6) — backs PUT /purchase-orders/{poId}'s If-Match (FR-PROC-05, Option B).
const mockPoVersions = new Map<number, number>(mockPurchaseOrders.map((p) => [p.poId, 1]))
export function getPoVersion(poId: number): number {
return mockPoVersions.get(poId) ?? 1
}
export function bumpPoVersion(poId: number): number {
const next = getPoVersion(poId) + 1
mockPoVersions.set(poId, next)
return next
}
export function initPoVersion(poId: number) {
mockPoVersions.set(poId, 1)
}
export const mockGrns: Grn[] = [
{
grnId: 780,
docNo: "GRN-2026-00780",
poId: 342,
vendorId: 5,
warehouseId: 1,
status: "Draft",
createdBy: 17,
createdAt: "2026-07-11T10:00:00Z",
lines: [
{
grnLineId: 1300,
poLineId: 900,
itemId: 1001,
uomId: 1,
binId: 1,
qty: 5000,
unitCost: 12.5,
receivedValue: 62500.0,
holdStatus: "OnHold",
batchId: 410,
},
],
},
{
grnId: 781,
docNo: "GRN-2026-00781",
poId: null,
vendorId: 8,
warehouseId: 2,
status: "Confirmed",
createdBy: 17,
createdAt: "2026-07-10T14:30:00Z",
lines: [
{
grnLineId: 1310,
poLineId: null,
itemId: 1003,
uomId: 1,
binId: 4,
qty: 5,
unitCost: 450.0,
receivedValue: 2250.0,
holdStatus: "Available",
batchId: null,
},
],
},
]
// A handful more so the list screen's pagination/search/filter controls have
// something real to page through (10 items total across statuses/warehouses).
const extraStatuses: Grn["status"][] = ["Draft", "Confirmed", "Closed", "Confirmed", "Draft", "Confirmed", "Closed", "Draft"]
for (let i = 0; i < extraStatuses.length; i++) {
const grnId = 782 + i
const warehouseId = i % 2 === 0 ? 1 : 2
const vendorId = i % 2 === 0 ? 5 : 8
const itemId = i % 2 === 0 ? 1001 : 1003
const status = extraStatuses[i]
mockGrns.push({
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: i % 3 === 0 ? null : 342,
vendorId,
warehouseId,
status,
createdBy: 17,
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
lines: [
{
grnLineId: 2000 + i,
poLineId: i % 3 === 0 ? null : 900,
itemId,
uomId: 1,
binId: warehouseId === 1 ? 1 : 4,
qty: 100 * (i + 1),
unitCost: 10 + i,
receivedValue: 100 * (i + 1) * (10 + i),
holdStatus: status === "Draft" ? "OnHold" : "Available",
batchId: null,
},
],
})
}
let nextGrnId = 782 + extraStatuses.length
let nextGrnLineId = 2000 + extraStatuses.length
export function allocateGrnId() {
return nextGrnId++
}
export function allocateGrnLineId() {
return nextGrnLineId++
}
/** Small delay so loading states are visible when reviewing the UI. */
export function mockDelay<T>(value: T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms))
}
// ============================================================================
// Stock Core (FIFO layers + immutable ledger) — docs/10 Part C.5, FR-STK-01..04.
// GRN confirm and every stock transaction below post through these helpers so
// Stock Enquiry / Ledger / Valuation reflect what actually happened this session.
// ============================================================================
export interface MockStockLayer {
layerId: number
itemId: number
warehouseId: number
batchId: number | null
serialId: number | null
grnLineId: number | null
qtyReceived: number
qtyRemaining: number
unitCost: number
receiptDate: string
}
export const mockStockLayers: MockStockLayer[] = []
export const mockStockLedger: LedgerEntry[] = []
let nextLayerId = 9001
let nextLedgerId = 55010
export function allocateLayerId() {
return nextLayerId++
}
export function allocateLedgerId() {
return nextLedgerId++
}
function round2(n: number) {
return Math.round(n * 100) / 100
}
function latestRunningBalance(itemId: number, warehouseId: number): number {
for (let i = mockStockLedger.length - 1; i >= 0; i--) {
const entry = mockStockLedger[i]
if (entry.itemId === itemId && entry.warehouseId === warehouseId) return entry.runningBalance
}
return 0
}
export function postLedgerEntry(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
userId: number
direction: LedgerDirection
qtyBase: number
unitCost: number
sourceDocType: string
sourceDocId: number
}): LedgerEntry {
const prior = latestRunningBalance(input.itemId, input.warehouseId)
const delta = input.direction === "In" ? input.qtyBase : -input.qtyBase
const entry: LedgerEntry = {
ledgerId: allocateLedgerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId ?? null,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
direction: input.direction,
qtyBase: input.qtyBase,
unitCost: input.unitCost,
value: round2(input.qtyBase * input.unitCost),
runningBalance: round2(prior + delta),
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
userId: input.userId,
createdAt: new Date().toISOString(),
}
mockStockLedger.push(entry)
return entry
}
/** Creates a FIFO layer + posts the matching inbound ledger entry (FR-GRN-06 / FR-STK-02). */
export function receiveLayer(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
grnLineId?: number | null
qty: number
unitCost: number
userId: number
sourceDocType: string
sourceDocId: number
}): { layer: MockStockLayer; ledger: LedgerEntry } {
const layer: MockStockLayer = {
layerId: allocateLayerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
grnLineId: input.grnLineId ?? null,
qtyReceived: input.qty,
qtyRemaining: input.qty,
unitCost: input.unitCost,
receiptDate: new Date().toISOString(),
}
mockStockLayers.push(layer)
const ledger = postLedgerEntry({
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId,
batchId: input.batchId,
serialId: input.serialId,
userId: input.userId,
direction: "In",
qtyBase: input.qty,
unitCost: input.unitCost,
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
})
return { layer, ledger }
}
/** 409 STOCK_NEGATIVE_BLOCKED (docs/11 §7) — thrown by consumeFifo when available < requested. */
export class StockNegativeError extends Error {
code = "STOCK_NEGATIVE_BLOCKED"
constructor(itemId: number, warehouseId: number) {
super(`Not enough available stock for item #${itemId} at warehouse #${warehouseId}.`)
}
}
/** A layer is unavailable while its originating GRN line is still on hold/rejected (docs/10 C.9). */
function isLayerOnHold(layer: MockStockLayer): boolean {
if (!layer.grnLineId) return false
for (const grn of mockGrns) {
const line = grn.lines.find((l) => l.grnLineId === layer.grnLineId)
if (line) return line.holdStatus === "OnHold" || line.holdStatus === "Rejected"
}
return false
}
/** Consumes the oldest open (non-held) layers first (FR-STK-03); throws StockNegativeError if insufficient. */
export function consumeFifo(
itemId: number,
warehouseId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number }[] {
let remaining = qty
const consumed: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const candidates = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0 && !isLayerOnHold(l))
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
for (const layer of candidates) {
if (remaining <= 0) break
const take = Math.min(layer.qtyRemaining, remaining)
layer.qtyRemaining = round2(layer.qtyRemaining - take)
remaining = round2(remaining - take)
consumed.push({ layerId: layer.layerId, qtyConsumed: take, unitCost: layer.unitCost })
}
if (remaining > 0.0001) throw new StockNegativeError(itemId, warehouseId)
return consumed
}
/** "Last cost" for an adjustment increase (FR-STK-07) when no more specific cost is supplied. */
export function lastKnownCost(itemId: number, warehouseId: number): number {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
.sort((a, b) => new Date(b.receiptDate).getTime() - new Date(a.receiptDate).getTime())
return layers[0]?.unitCost ?? 10
}
/**
* Consumes stock for a Purchase Return against the specific layer its GRN line created
* (FR-PROC-08) — deliberately not routed through consumeFifo: a return disposes of the
* exact received batch (often On-hold/Rejected, which consumeFifo's isLayerOnHold filter
* would otherwise skip), not just "the oldest open layer for this item/warehouse".
* Throws StockNegativeError (409 STOCK_NEGATIVE_BLOCKED, docs/11 §3.4) if the return
* qty exceeds what remains on that layer.
*/
export function consumeLayerByGrnLine(
grnLineId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number; itemId: number; warehouseId: number } {
const layer = mockStockLayers.find((l) => l.grnLineId === grnLineId)
if (!layer || layer.qtyRemaining < qty) {
const itemId = layer?.itemId ?? 0
const warehouseId = layer?.warehouseId ?? 0
throw new StockNegativeError(itemId, warehouseId)
}
layer.qtyRemaining = round2(layer.qtyRemaining - qty)
return { layerId: layer.layerId, qtyConsumed: qty, unitCost: layer.unitCost, itemId: layer.itemId, warehouseId: layer.warehouseId }
}
export function computeOnHand(itemId: number, warehouseId: number) {
const layers = mockStockLayers.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
const onHand = round2(layers.reduce((sum, l) => sum + l.qtyRemaining, 0))
const onHold = round2(layers.filter(isLayerOnHold).reduce((sum, l) => sum + l.qtyRemaining, 0))
const inTransit = round2(
mockStockTransfers
.filter((t) => t.status === "InTransit" && t.destWarehouseId === warehouseId)
.flatMap((t) => t.lines)
.filter((l) => l.itemId === itemId)
.reduce((sum, l) => sum + l.qty, 0)
)
const reserved = 0
const available = Math.max(0, round2(onHand - onHold - reserved))
return { onHand, onHold, inTransit, reserved, available }
}
/** Every item/warehouse combination that currently has (or ever had) a layer — drives the Enquiry screen. */
export function knownStockKeys(): { itemId: number; warehouseId: number }[] {
const seen = new Map<string, { itemId: number; warehouseId: number }>()
for (const layer of mockStockLayers) {
seen.set(`${layer.itemId}:${layer.warehouseId}`, { itemId: layer.itemId, warehouseId: layer.warehouseId })
}
return [...seen.values()]
}
// --- Reference data (docs/11 §6) --------------------------------------------------
export interface MockItemReorder {
itemId: number
warehouseId: number
reorderPoint: number
reorderQty: number
}
export const mockItemReorders: MockItemReorder[] = [
{ itemId: 1001, warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 },
{ itemId: 1002, warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 },
{ itemId: 1003, warehouseId: 2, reorderPoint: 15, reorderQty: 20 },
]
export const mockReasonCodes: ReasonCode[] = [
{ reasonCodeId: 1, code: "DMG", description: "Damage", context: "Adjustment" },
{ reasonCodeId: 2, code: "LOSS", description: "Theft/Loss", context: "Adjustment" },
{ reasonCodeId: 3, code: "CNTVAR", description: "Count Variance", context: "Adjustment" },
{ reasonCodeId: 4, code: "EXPWO", description: "Expiry Write-off", context: "Adjustment" },
{ reasonCodeId: 5, code: "SYSCORR", description: "System Correction", context: "Adjustment" },
{ reasonCodeId: 22, code: "QREJ", description: "Quality Reject", context: "Return" },
]
// --- Seed some prior receipts so Enquiry/Ledger/Valuation aren't empty on first load ---
receiveLayer({
itemId: 1001, warehouseId: 1, binId: 1, batchId: 411, qty: 3000, unitCost: 12.5,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1002, warehouseId: 1, binId: 2, qty: 6000, unitCost: 6.2,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1003, warehouseId: 2, binId: 4, qty: 20, unitCost: 450,
userId: 17, sourceDocType: "GRN", sourceDocId: 781,
})
// ============================================================================
// Transfers (FR-STK-05/06) — create → dispatch (consume src) → receive (create dest).
// ============================================================================
export interface MockTransferLine {
transferLineId: number
itemId: number
srcBinId: number | null
destBinId: number | null
batchId: number | null
qty: number
/** Recorded on dispatch so receive() can create cost-preserving destination layers (FR-STK-06). */
dispatchedChunks: { layerId: number; qtyConsumed: number; unitCost: number }[]
}
export interface MockStockTransfer {
transferId: number
docNo: string
srcWarehouseId: number
destWarehouseId: number
status: TransferStatus
createdBy: number
createdAt: string
lines: MockTransferLine[]
}
export const mockStockTransfers: MockStockTransfer[] = []
let nextTransferId = 55
let nextTransferLineId = 300
export function allocateTransferId() {
return nextTransferId++
}
export function allocateTransferLineId() {
return nextTransferLineId++
}
// ============================================================================
// Adjustments (FR-STK-07) — auto-post on creation.
// ============================================================================
export interface MockAdjustmentLine {
adjLineId: number
itemId: number
binId: number | null
batchId: number | null
qtyDelta: number
}
export interface MockStockAdjustment {
adjustmentId: number
docNo: string
warehouseId: number
reasonCodeId: number
status: AdjustmentStatus
createdBy: number
createdAt: string
lines: MockAdjustmentLine[]
ledgerRefs: number[]
}
export const mockStockAdjustments: MockStockAdjustment[] = []
let nextAdjustmentId = 77
let nextAdjLineId = 210
export function allocateAdjustmentId() {
return nextAdjustmentId++
}
export function allocateAdjLineId() {
return nextAdjLineId++
}
// ============================================================================
// Counts (FR-STK-08) — snapshot system qty → enter counted qty → post variance.
// ============================================================================
export interface MockCountLine {
countLineId: number
itemId: number
binId: number | null
systemQty: number
countedQty: number | null
variance: number | null
}
export interface MockStockCount {
countId: number
docNo: string
warehouseId: number
countType: CountType
status: CountStatus
createdBy: number
createdAt: string
lines: MockCountLine[]
}
export const mockStockCounts: MockStockCount[] = []
let nextCountId = 30
let nextCountLineId = 400
export function allocateCountId() {
return nextCountId++
}
export function allocateCountLineId() {
return nextCountLineId++
}
// ============================================================================
// Procurement (FR-PROC-01..09) — Requisition → RFQ → Quotations → PO → Return.
// No Procurement backend exists yet; same frontend-only posture as GRN/Stock.
// ============================================================================
export const mockRequisitions: Requisition[] = [
// Seeded to match mockPurchaseOrders[0].requisitionId (PO-2026-00342 was raised
// against this requisition) so the two screens cross-reference consistently.
{
requisitionId: 210,
docNo: "PR-2026-00210",
status: "Submitted",
requestedBy: 17,
createdAt: "2026-07-06T08:30:00Z",
lines: [
{ reqLineId: 501, itemId: 1001, qty: 5000, requiredBy: "2026-07-20" },
{ reqLineId: 502, itemId: 1002, qty: 8000, requiredBy: "2026-07-20" },
],
},
]
let nextRequisitionId = 211
let nextReqLineId = 503
export function allocateRequisitionId() {
return nextRequisitionId++
}
export function allocateReqLineId() {
return nextReqLineId++
}
export const mockRfqs: Rfq[] = []
let nextRfqId = 89
let nextRfqLineId = 703
export function allocateRfqId() {
return nextRfqId++
}
export function allocateRfqLineId() {
return nextRfqLineId++
}
export const mockQuotations: Quotation[] = []
let nextQuotationId = 141
export function allocateQuotationId() {
return nextQuotationId++
}
export const mockPurchaseReturns: PurchaseReturn[] = []
let nextPurchaseReturnId = 61
let nextPurchaseReturnLineId = 121
export function allocatePurchaseReturnId() {
return nextPurchaseReturnId++
}
export function allocatePurchaseReturnLineId() {
return nextPurchaseReturnLineId++
}
@@ -0,0 +1,25 @@
// Product configuration (docs/11-BACKEND-PHASE1.md §2.8; FR-MD-11). Singleton — no id.
//
// `subcategoriesEnabled`/`brandsEnabled` are enforced server-side: an item write carrying
// a gated field while its flag is off returns 422 CONFIG_DISABLED. `itemTypesEnabled` is
// ADVISORY — items hold no item-type reference, so there is nothing for the server to
// reject; this frontend is what honours it by hiding the builder's type section.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
export const productConfigApi = {
get(): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config")
},
/** All three flags are required; a partial body is a 400, never a silent disable. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
}
/** Read-only helper for screens that only need the flags. */
export function productConfig(): Promise<ProductConfig> {
return apiRequest<ProductConfig>("/product-config")
}
+18 -119
View File
@@ -1,6 +1,6 @@
// One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3,
// FR-PROC-03..07). `get`/`list` also back the GRN "against a PO" picker.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CancelPurchaseOrderRequest,
@@ -10,14 +10,6 @@ import {
PurchaseOrderSummary,
UpdatePurchaseOrderRequest,
} from "@/types/procurement"
import {
allocatePoId,
bumpPoVersion,
getPoVersion,
initPoVersion,
mockDelay,
mockPurchaseOrders,
} from "@/lib/api/mock-data"
export interface ListPurchaseOrdersParams {
page?: number
@@ -25,138 +17,45 @@ export interface ListPurchaseOrdersParams {
q?: string
status?: PurchaseOrderStatus
vendorId?: number
sort?: string
}
/** FR-PROC-05 (Option B): freely editable while open — not once fully received/closed/cancelled. */
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
}
function computeTotals(lines: CreatePurchaseOrderRequest["lines"], currency = "LKR") {
const subTotal = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0) * 100) / 100
const tax = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice * l.tax, 0) * 100) / 100
return { subTotal, tax, grandTotal: Math.round((subTotal + tax) * 100) / 100, currency }
}
export const purchaseOrdersApi = {
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockPurchaseOrders
.filter((po) => !params.status || po.status === params.status)
.filter((po) => !params.vendorId || po.vendorId === params.vendorId)
.filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term))
.sort((a, b) => b.poId - a.poId)
.map(
(po): PurchaseOrderSummary => ({
poId: po.poId,
docNo: po.docNo,
vendorId: po.vendorId,
status: po.status,
approvalRequired: po.approvalRequired,
createdAt: po.createdAt,
totals: po.totals,
})
)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
},
get(poId: number): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay(po)
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
},
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
},
/** Auto-approved on creation in Phase 1 (approvalRequired defaults false, FR-PROC-04). */
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const poId = allocatePoId()
// FR-PROC-04: approvalRequired defaults false → auto-approved on creation.
const po: PurchaseOrder = {
poId,
docNo: `PO-2026-${String(poId).padStart(5, "0")}`,
vendorId: request.vendorId,
requisitionId: request.requisitionId ?? null,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: new Date().toISOString(),
updatedAt: null,
totals: computeTotals(request.lines),
lines: request.lines.map((l, i) => ({
poLineId: 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: 0,
})),
}
mockPurchaseOrders.push(po)
initPoVersion(poId)
return mockDelay({ data: po, etag: "1" })
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
},
/** 409 PO_NOT_EDITABLE once fully received/closed/cancelled. */
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (!isPoEditable(po.status)) {
return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" }))
}
if (String(getPoVersion(poId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived]))
po.vendorId = request.vendorId
po.requisitionId = request.requisitionId ?? null
po.totals = computeTotals(request.lines, po.totals.currency)
po.lines = request.lines.map((l, i) => {
// Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress).
const existingLineId = po.lines[i]?.poLineId
return {
poLineId: existingLineId ?? 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0,
}
})
po.updatedAt = new Date().toISOString()
const next = bumpPoVersion(poId)
return mockDelay({ data: po, etag: String(next) })
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
},
/** No-op in Phase 1 unless approvals are enabled (FR-PROC-04). */
approve(poId: number): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
},
/** 409 if any receipt exists against the PO. */
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (po.lines.some((l) => l.qtyReceived > 0)) {
return Promise.reject(new Error(`${po.docNo} has receipts against it and can no longer be cancelled.`))
}
if (!request.reason.trim()) return Promise.reject(new Error("A cancellation reason is required."))
po.status = "Cancelled"
po.updatedAt = new Date().toISOString()
bumpPoVersion(poId)
return mockDelay(po)
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
},
}
+18 -79
View File
@@ -1,93 +1,32 @@
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4,
// FR-PROC-08). Auto-posts an outbound FIFO movement on create.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
import {
allocatePurchaseReturnId,
allocatePurchaseReturnLineId,
consumeLayerByGrnLine,
mockDelay,
mockPurchaseReturns,
postLedgerEntry,
} from "@/lib/api/mock-data"
function toSummary(r: PurchaseReturn): PurchaseReturnSummary {
return {
returnId: r.returnId,
docNo: r.docNo,
vendorId: r.vendorId,
warehouseId: r.warehouseId,
reasonCodeId: r.reasonCodeId,
status: r.status,
createdAt: r.createdAt,
}
export interface ListPurchaseReturnsParams {
page?: number
pageSize?: number
q?: string
vendorId?: number
warehouseId?: number
sort?: string
}
export const purchaseReturnsApi = {
list(): Promise<PagedResponse<PurchaseReturnSummary>> {
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(params: ListPurchaseReturnsParams = {}): Promise<PagedResponse<PurchaseReturnSummary>> {
return apiRequest<PagedResponse<PurchaseReturnSummary>>(`/purchase-returns${buildQuery(params)}`)
},
get(returnId: number): Promise<PurchaseReturn> {
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
return mockDelay(r)
return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
},
/**
* 400 REASON_CODE_REQUIRED without a reason; 422 if it is not a Return-context reason;
* 409 STOCK_NEGATIVE_BLOCKED if the return exceeds available stock.
*/
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for returns."), { code: "REASON_CODE_REQUIRED" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const returnId = allocatePurchaseReturnId()
const ledgerRefs: number[] = []
const lines: PurchaseReturn["lines"] = []
try {
for (const line of request.lines) {
// FR-PROC-08: consumes the exact layer the GRN line created; throws
// StockNegativeError (409 STOCK_NEGATIVE_BLOCKED) if qty exceeds it.
const chunk = consumeLayerByGrnLine(line.grnLineId, line.qty)
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: chunk.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "PurchaseReturn",
sourceDocId: returnId,
})
ledgerRefs.push(ledger.ledgerId)
lines.push({
returnLineId: allocatePurchaseReturnLineId(),
grnLineId: line.grnLineId,
itemId: line.itemId,
qty: line.qty,
})
}
} catch (err) {
return Promise.reject(err)
}
const purchaseReturn: PurchaseReturn = {
returnId,
docNo: `PRET-2026-${String(returnId).padStart(5, "0")}`,
vendorId: request.vendorId,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockPurchaseReturns.push(purchaseReturn)
return mockDelay(purchaseReturn)
return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
},
}
+10 -8
View File
@@ -1,15 +1,17 @@
// One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { ReasonCode, ReasonCodeContext } from "@/types/stock"
import { mockDelay, mockReasonCodes } from "@/lib/api/mock-data"
export interface ListReasonCodesParams {
context?: ReasonCodeContext
page?: number
pageSize?: number
q?: string
}
export const reasonCodesApi = {
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
const items = mockReasonCodes.filter((r) => !context || r.context === context)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(context?: ReasonCodeContext, params: Omit<ListReasonCodesParams, "context"> = {}): Promise<PagedResponse<ReasonCode>> {
return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context, ...params })}`)
},
}
+8 -51
View File
@@ -1,74 +1,31 @@
// One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data"
export interface ListRequisitionsParams {
page?: number
pageSize?: number
q?: string
status?: RequisitionStatus
}
function toSummary(r: Requisition): RequisitionSummary {
return {
requisitionId: r.requisitionId,
docNo: r.docNo,
status: r.status,
requestedBy: r.requestedBy,
createdAt: r.createdAt,
lineCount: r.lines.length,
}
sort?: string
}
export const requisitionsApi = {
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
const filtered = mockRequisitions
.filter((r) => !params.status || r.status === params.status)
.map(toSummary)
.sort((a, b) => b.requisitionId - a.requisitionId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
},
get(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
return mockDelay(r)
return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
},
/** `requestedBy` is stamped from the session, never posted. */
create(request: CreateRequisitionRequest): Promise<Requisition> {
if (request.lines.length === 0) {
return Promise.reject(new Error("A requisition needs at least one line."))
}
const requisition: Requisition = {
requisitionId: allocateRequisitionId(),
docNo: "",
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })),
}
requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}`
mockRequisitions.push(requisition)
return mockDelay(requisition)
return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
},
submit(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
if (r.status !== "Draft") {
return Promise.reject(new Error(`${r.docNo} has already been submitted.`))
}
r.status = "Submitted"
return mockDelay(r)
return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST" })
},
}
+19 -72
View File
@@ -1,5 +1,8 @@
// One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
//
// Note `vendorIds` is validated on create but not persisted — there is no RFQ↔vendor link
// in the model, so an Rfq comes back without them; quotations reference vendors directly.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateQuotationRequest,
@@ -7,94 +10,38 @@ import {
Quotation,
Rfq,
RfqComparison,
RfqComparisonLine,
RfqStatus,
RfqSummary,
} from "@/types/procurement"
import {
allocateQuotationId,
allocateRfqId,
allocateRfqLineId,
mockDelay,
mockQuotations,
mockRfqs,
} from "@/lib/api/mock-data"
function toSummary(r: Rfq): RfqSummary {
return {
rfqId: r.rfqId,
docNo: r.docNo,
requisitionId: r.requisitionId,
status: r.status,
vendorIds: r.vendorIds,
createdAt: r.createdAt,
}
export interface ListRfqsParams {
page?: number
pageSize?: number
q?: string
status?: RfqStatus
sort?: string
}
export const rfqsApi = {
list(): Promise<PagedResponse<RfqSummary>> {
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(params: ListRfqsParams = {}): Promise<PagedResponse<RfqSummary>> {
return apiRequest<PagedResponse<RfqSummary>>(`/rfqs${buildQuery(params)}`)
},
get(rfqId: number): Promise<Rfq> {
const r = mockRfqs.find((x) => x.rfqId === rfqId)
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
return mockDelay(r)
return apiRequest<Rfq>(`/rfqs/${rfqId}`)
},
create(request: CreateRfqRequest): Promise<Rfq> {
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const rfq: Rfq = {
rfqId: allocateRfqId(),
docNo: "",
requisitionId: request.requisitionId ?? null,
status: "Open",
vendorIds: request.vendorIds,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })),
}
rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}`
mockRfqs.push(rfq)
return mockDelay(rfq)
return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
},
/** One quotation per vendor per RFQ; a duplicate returns 409. */
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
if (!rfq.vendorIds.includes(request.vendorId)) {
return Promise.reject(new Error("This vendor was not invited to the RFQ."))
}
const quotation: Quotation = {
quotationId: allocateQuotationId(),
rfqId,
vendorId: request.vendorId,
createdAt: new Date().toISOString(),
lines: request.lines,
}
mockQuotations.push(quotation)
return mockDelay(quotation)
return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
},
/** Server-computed vendor-by-line price matrix. */
comparison(rfqId: number): Promise<RfqComparison> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
const quotations = mockQuotations.filter((q) => q.rfqId === rfqId)
const lines: RfqComparisonLine[] = rfq.lines.map((rfqLine) => ({
itemId: rfqLine.itemId,
qty: rfqLine.qty,
cells: quotations
.map((q) => {
const line = q.lines.find((l) => l.itemId === rfqLine.itemId)
return line ? { vendorId: q.vendorId, unitPrice: line.unitPrice, leadDays: line.leadDays } : null
})
.filter((c): c is { vendorId: number; unitPrice: number; leadDays: number } => c !== null),
}))
return mockDelay({ rfqId, vendorIds: rfq.vendorIds, lines })
return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
},
}
+13 -105
View File
@@ -1,124 +1,32 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// Auto-posts on create with a mandatory reason code (FR-STK-07).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
export interface ListAdjustmentsParams {
page?: number
pageSize?: number
q?: string
warehouseId?: number
}
function toSummary(a: (typeof mockStockAdjustments)[number]): StockAdjustmentSummary {
return {
adjustmentId: a.adjustmentId,
docNo: a.docNo,
warehouseId: a.warehouseId,
reasonCodeId: a.reasonCodeId,
status: a.status,
createdAt: a.createdAt,
}
reasonCodeId?: number
sort?: string
}
export const stockAdjustmentsApi = {
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
const filtered = mockStockAdjustments
.filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.adjustmentId - a.adjustmentId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
},
get(adjustmentId: number): Promise<StockAdjustment> {
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
return mockDelay(a)
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
},
/**
* 400 REASON_CODE_REQUIRED without a reason; 422 if it is not an Adjustment-context
* reason or qtyDelta is 0; 409 STOCK_NEGATIVE_BLOCKED if a decrease exceeds available.
*/
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for adjustments."), { code: "REASON_CODE_REQUIRED" }))
}
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const lines: StockAdjustment["lines"] = []
try {
for (const line of request.lines) {
const adjLineId = allocateAdjLineId()
lines.push({ adjLineId, itemId: line.itemId, binId: line.binId ?? null, batchId: line.batchId ?? null, qtyDelta: line.qtyDelta })
if (line.qtyDelta > 0) {
// FR-STK-07: increase creates a layer at the last known cost.
const unitCost = lastKnownCost(line.itemId, request.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
qty: line.qtyDelta,
unitCost,
userId: 17,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
} else if (line.qtyDelta < 0) {
// Decrease consumes FIFO layers (409 STOCK_NEGATIVE_BLOCKED if insufficient).
const chunks = consumeFifo(line.itemId, request.warehouseId, Math.abs(line.qtyDelta))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
} catch (err) {
return Promise.reject(err)
}
const adjustment = {
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted" as AdjustmentStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockStockAdjustments.push(adjustment)
return mockDelay(adjustment)
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
}
+13 -132
View File
@@ -1,164 +1,45 @@
// One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// create (snapshots system qty) -> enterCounts -> post (creates the variance adjustment).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CountStatus,
CreateCountRequest,
EnterCountsRequest,
EnterCountsResponse,
PostCountResponse,
StockCount,
StockCountSummary,
} from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
allocateCountId,
allocateCountLineId,
computeOnHand,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
mockStockCounts,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
export interface ListCountsParams {
page?: number
pageSize?: number
q?: string
status?: CountStatus
warehouseId?: number
}
function toSummary(c: (typeof mockStockCounts)[number]): StockCountSummary {
return {
countId: c.countId,
docNo: c.docNo,
warehouseId: c.warehouseId,
countType: c.countType,
status: c.status,
createdAt: c.createdAt,
}
sort?: string
}
export const stockCountsApi = {
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
const filtered = mockStockCounts
.filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.countId - a.countId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
},
get(countId: number): Promise<StockCount> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
return mockDelay(c)
return apiRequest<StockCount>(`/stock-counts/${countId}`)
},
create(request: CreateCountRequest): Promise<StockCount> {
const countId = allocateCountId()
const count = {
countId,
docNo: `CNT-2026-${String(countId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
countType: request.countType,
status: "Draft" as CountStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.itemIds.map((itemId) => ({
countLineId: allocateCountLineId(),
itemId,
binId: null,
systemQty: computeOnHand(itemId, request.warehouseId).onHand,
countedQty: null,
variance: null,
})),
}
mockStockCounts.push(count)
return mockDelay(count)
return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
},
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
for (const input of request.lines) {
const line = c.lines.find((l) => l.countLineId === input.countLineId)
if (!line) continue
line.countedQty = input.countedQty
line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100
}
return mockDelay({ lines: c.lines })
/** Returns the whole count (with server-computed variance), not just the lines. */
enterCounts(countId: number, request: EnterCountsRequest): Promise<StockCount> {
return apiRequest<StockCount>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
},
/** Posts the variance adjustment and closes the count. `adjustmentId` is null if there was no variance. */
post(countId: number): Promise<PostCountResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
if (c.status === "Posted") return Promise.reject(new Error(`${c.docNo} has already been posted.`))
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const adjLines: { adjLineId: number; itemId: number; binId: number | null; batchId: number | null; qtyDelta: number }[] = []
for (const line of c.lines) {
if (!line.variance) continue
adjLines.push({ adjLineId: allocateAdjLineId(), itemId: line.itemId, binId: line.binId, batchId: null, qtyDelta: line.variance })
if (line.variance > 0) {
const unitCost = lastKnownCost(line.itemId, c.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: c.warehouseId,
qty: line.variance,
unitCost,
userId: 17,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
} else {
const chunks = consumeFifo(line.itemId, c.warehouseId, Math.abs(line.variance))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: c.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
// Count Variance reason code (docs/8.3 seed list) — posted as its own adjustment record.
mockStockAdjustments.push({
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: c.warehouseId,
reasonCodeId: 3,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: adjLines,
ledgerRefs,
})
c.status = "Posted"
return mockDelay({ countId: c.countId, status: c.status, adjustmentId, ledgerRefs })
return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST" })
},
}
+14 -146
View File
@@ -1,5 +1,7 @@
// One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// create -> dispatch -> receive. The server consumes source FIFO layers on dispatch and
// creates the destination layer at the inherited cost on receive (cost-preserving, FR-STK-06).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateTransferRequest,
@@ -10,173 +12,39 @@ import {
StockTransferSummary,
TransferStatus,
} from "@/types/stock"
import {
MockTransferLine,
allocateTransferId,
allocateTransferLineId,
consumeFifo,
mockDelay,
mockStockTransfers,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
export interface ListTransfersParams {
page?: number
pageSize?: number
q?: string
status?: TransferStatus
srcWarehouseId?: number
destWarehouseId?: number
}
function toPublicLine(line: MockTransferLine) {
return {
transferLineId: line.transferLineId,
itemId: line.itemId,
srcBinId: line.srcBinId,
destBinId: line.destBinId,
batchId: line.batchId,
qty: line.qty,
}
}
function toSummary(t: (typeof mockStockTransfers)[number]): StockTransferSummary {
return {
transferId: t.transferId,
docNo: t.docNo,
srcWarehouseId: t.srcWarehouseId,
destWarehouseId: t.destWarehouseId,
status: t.status,
createdAt: t.createdAt,
}
}
function toPublic(t: (typeof mockStockTransfers)[number]): StockTransfer {
return { ...toSummary(t), createdBy: t.createdBy, lines: t.lines.map(toPublicLine) }
sort?: string
}
export const stockTransfersApi = {
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
const filtered = mockStockTransfers
.filter((t) => !params.status || t.status === params.status)
.filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId)
.filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId)
.map(toSummary)
.sort((a, b) => b.transferId - a.transferId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
},
get(transferId: number): Promise<StockTransfer> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
return mockDelay(toPublic(t))
return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
},
create(request: CreateTransferRequest): Promise<StockTransfer> {
const transferId = allocateTransferId()
const t = {
transferId,
docNo: `TRF-2026-${String(transferId).padStart(5, "0")}`,
srcWarehouseId: request.srcWarehouseId,
destWarehouseId: request.destWarehouseId,
status: "Draft" as TransferStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({
transferLineId: allocateTransferLineId(),
itemId: l.itemId,
srcBinId: l.srcBinId ?? null,
destBinId: l.destBinId ?? null,
batchId: l.batchId ?? null,
qty: l.qty,
dispatchedChunks: [],
})),
}
mockStockTransfers.push(t)
return mockDelay(toPublic(t))
return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
},
/** 409 STOCK_NEGATIVE_BLOCKED if source available < requested. */
dispatch(transferId: number): Promise<DispatchTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "Draft") return Promise.reject(new Error(`${t.docNo} has already been dispatched.`))
const consumedLayers: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
try {
for (const line of t.lines) {
const chunks = consumeFifo(line.itemId, t.srcWarehouseId, line.qty)
line.dispatchedChunks = chunks
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: t.srcWarehouseId,
binId: line.srcBinId,
batchId: line.batchId,
userId: t.createdBy,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
consumedLayers.push(chunk)
ledgerRefs.push(ledger.ledgerId)
}
}
} catch (err) {
return Promise.reject(err)
}
t.status = "InTransit"
return mockDelay({ transferId: t.transferId, status: t.status, consumedLayers, ledgerRefs })
return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST" })
},
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`))
const createdLayers: { layerId: number; warehouseId: number; qtyReceived: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
for (const input of lines) {
const line = t.lines.find((l) => l.transferLineId === input.transferLineId)
if (!line) continue
// Cost-preserving (FR-STK-06): one destination layer per dispatched chunk, at its exact source cost.
for (const chunk of line.dispatchedChunks) {
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: t.destWarehouseId,
binId: line.destBinId,
batchId: line.batchId,
qty: chunk.qtyConsumed,
unitCost: chunk.unitCost,
userId: t.createdBy,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
createdLayers.push({
layerId: layer.layerId,
warehouseId: layer.warehouseId,
qtyReceived: layer.qtyReceived,
unitCost: layer.unitCost,
})
ledgerRefs.push(ledger.ledgerId)
}
}
t.status = "Received"
return mockDelay({ transferId: t.transferId, status: t.status, createdLayers, ledgerRefs })
return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
method: "POST",
body: { lines },
})
},
}
+30 -117
View File
@@ -1,147 +1,60 @@
// One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock"
import {
allocateReqLineId,
allocateRequisitionId,
computeOnHand,
knownStockKeys,
mockDelay,
mockItemReorders,
mockRequisitions,
mockStockLayers,
mockStockLedger,
} from "@/lib/api/mock-data"
export interface LedgerQuery {
itemId?: number
warehouseId?: number
/** `YYYY-MM-DD`. */
from?: string
to?: string
/**
* Document-type prefix as stored on the ledger: "GRN", "ADJ", "TRF", "PRET", "CNT"
* (Domain/DocumentTypes.cs) — not the friendly name. Pair with sourceDocId to ask
* "what movements did this document post?".
*/
sourceDocType?: string
sourceDocId?: number
page?: number
pageSize?: number
}
export interface OnHandListParams {
itemId?: number
warehouseId?: number
page?: number
pageSize?: number
}
export const stockApi = {
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
const computed = computeOnHand(itemId, warehouseId)
return mockDelay({
itemId,
warehouseId,
...computed,
asOf: new Date().toISOString(),
})
return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
},
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
onHandList(): Promise<OnHand[]> {
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
itemId,
warehouseId,
...computeOnHand(itemId, warehouseId),
asOf: new Date().toISOString(),
}))
return mockDelay(rows)
/** Every (item, warehouse) pair holding stock. Paged, unlike the old client-side version. */
onHandList(params: OnHandListParams = {}): Promise<PagedResponse<OnHand>> {
return apiRequest<PagedResponse<OnHand>>(`/stock/on-hand/list${buildQuery(params)}`)
},
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
const from = params.from ? new Date(params.from).getTime() : null
const to = params.to ? new Date(params.to).getTime() : null
const filtered = mockStockLedger
.filter((e) => !params.itemId || e.itemId === params.itemId)
.filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId)
.filter((e) => from === null || new Date(e.createdAt).getTime() >= from)
.filter((e) => to === null || new Date(e.createdAt).getTime() <= to)
.sort((a, b) => b.ledgerId - a.ledgerId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: {
page,
pageSize,
totalItems: filtered.length,
totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize),
},
})
return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
},
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0)
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
.map((l) => ({
layerId: l.layerId,
qtyRemaining: l.qtyRemaining,
unitCost: l.unitCost,
value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100,
receiptDate: l.receiptDate,
}))
const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0)
const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100
return mockDelay({
itemId,
warehouseId,
layers,
totalQty,
totalValue,
currency: "LKR",
costingMethod: "FIFO",
})
return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
},
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
const items = mockItemReorders
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
.map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available }))
.filter((r) => r.available <= r.reorderPoint)
.map((r) => ({
itemId: r.itemId,
warehouseId: r.warehouseId,
available: r.available,
reorderPoint: r.reorderPoint,
reorderQty: r.reorderQty,
suggestedRequisitionQty: r.reorderQty,
}))
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
/** Items at or below their reorder point — computed on read, no stored entity (FR-STK-10). */
reorderAlerts(warehouseId?: number, params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<ReorderAlert>> {
return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId, ...params })}`)
},
/** Creates a draft requisition for the suggested qty; returns the full requisition. */
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId)
const qty = setting?.reorderQty ?? 0
const requisitionId = allocateRequisitionId()
const requiredBy = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
// Genuinely lands in the Requisitions list (§3), not a fabricated response —
// same "wire mock modules together" posture as GRN confirm → Stock Core.
mockRequisitions.push({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: [{ reqLineId: allocateReqLineId(), itemId, qty, requiredBy }],
})
return mockDelay({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
itemId,
warehouseId,
qty,
status: "Draft",
})
return apiRequest<ReorderRequisitionResponse>(
`/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
{ method: "POST" },
)
},
}
+12 -16
View File
@@ -1,26 +1,22 @@
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
// `list` also backs the GRN/PO line UOM picker built in earlier sessions.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// `list` also backs the GRN/PO line UOM picker.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateUomRequest, Uom } from "@/types/master-data"
import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data"
export interface ListUomsParams {
page?: number
pageSize?: number
q?: string
sort?: string
}
export const uomsApi = {
list(): Promise<PagedResponse<Uom>> {
return mockDelay({
items: mockUoms,
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
})
list(params: ListUomsParams = {}): Promise<PagedResponse<Uom>> {
return apiRequest<PagedResponse<Uom>>(`/uoms${buildQuery(params)}`)
},
create(request: CreateUomRequest): Promise<Uom> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("UOM name is required."))
if (mockUoms.some((u) => u.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`UOM "${name}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const uom: Uom = { uomId: allocateUomId(), name }
mockUoms.push(uom)
return mockDelay(uom)
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
},
}
-49
View File
@@ -1,49 +0,0 @@
// One typed client method per Variant Category endpoint, mirroring lib/api/brands.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { CreateVariantCategoryRequest, UpdateVariantCategoryRequest, VariantCategory } from "@/types/master-data"
import { allocateVariantCategoryId, mockVariantCategories, mockDelay } from "@/lib/api/mock-data"
export const variantCategoriesApi = {
list(): Promise<PagedResponse<VariantCategory>> {
const items = [...mockVariantCategories].sort((a, b) => a.name.localeCompare(b.name))
return mockDelay({
items,
pagination: { page: 1, pageSize: items.length || 1, totalItems: items.length, totalPages: 1 },
})
},
create(request: CreateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
if (mockVariantCategories.some((c) => c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
const category: VariantCategory = {
variantCategoryId: allocateVariantCategoryId(),
name,
createdAt: new Date().toISOString(),
}
mockVariantCategories.push(category)
return mockDelay(category)
},
update(variantCategoryId: number, request: UpdateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockVariantCategories.find((c) => c.variantCategoryId === variantCategoryId)
if (!category) return Promise.reject(new Error("Variant category not found."))
if (mockVariantCategories.some((c) => c.variantCategoryId !== variantCategoryId && c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
category.name = name
return mockDelay(category)
},
remove(variantCategoryId: number): Promise<void> {
const index = mockVariantCategories.findIndex((c) => c.variantCategoryId === variantCategoryId)
if (index === -1) return Promise.reject(new Error("Variant category not found."))
mockVariantCategories.splice(index, 1)
return mockDelay(undefined)
},
}
+10 -94
View File
@@ -1,120 +1,36 @@
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters
// are deactivated, not hard-deleted, FR-MD-08). In-memory sample data
// (lib/api/mock-data.ts) — no backend API calls.
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are
// deactivated, not hard-deleted, FR-MD-08).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { Vendor } from "@/types/master-data"
import {
allocateVendorId,
bumpVendorVersion,
getVendorVersion,
initVendorVersion,
mockDelay,
mockVendors,
} from "@/lib/api/mock-data"
import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data"
export interface ListVendorsParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
}
export interface CreateVendorRequest {
code: string
name: string
terms?: string | null
taxReg?: string | null
currency: string
}
export type UpdateVendorRequest = CreateVendorRequest
function codeTaken(code: string, excludeVendorId?: number) {
return mockVendors.some((v) => v.vendorId !== excludeVendorId && v.code.toLowerCase() === code.toLowerCase())
sort?: string
}
export const vendorsApi = {
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockVendors
.filter((v) => !params.status || v.status === params.status)
.filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
},
get(vendorId: number): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
},
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const vendor: Vendor = {
vendorId: allocateVendorId(),
code,
name: request.name.trim(),
terms: request.terms?.trim() || null,
taxReg: request.taxReg?.trim() || null,
currency: request.currency.trim().toUpperCase(),
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockVendors.push(vendor)
initVendorVersion(vendor.vendorId)
return mockDelay({ data: vendor, etag: "1" })
return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
},
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
if (String(getVendorVersion(vendorId)) !== ifMatch) {
return Promise.reject(
Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" })
)
}
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code, vendorId)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
v.code = code
v.name = request.name.trim()
v.terms = request.terms?.trim() || null
v.taxReg = request.taxReg?.trim() || null
v.currency = request.currency.trim().toUpperCase()
v.updatedAt = new Date().toISOString()
const next = bumpVendorVersion(vendorId)
return mockDelay({ data: v, etag: String(next) })
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
v.status = status
v.updatedAt = new Date().toISOString()
bumpVendorVersion(vendorId)
return mockDelay(undefined)
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
},
}
+19 -40
View File
@@ -1,60 +1,39 @@
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
// FR-MD-07/FR-WH-01). In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// FR-MD-07/FR-WH-01).
//
// Warehouses are create-and-list only: there is no PUT, no status, and no ETag on them.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data"
import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data"
import { Bin, CreateBinRequest, CreateWarehouseRequest, Warehouse } from "@/types/master-data"
export interface CreateWarehouseRequest {
code: string
name: string
}
export type { CreateBinRequest, CreateWarehouseRequest }
export interface CreateBinRequest {
code: string
binType?: string | null
export interface ListWarehousesParams {
page?: number
pageSize?: number
q?: string
sort?: string
}
export const warehousesApi = {
list(): Promise<PagedResponse<Warehouse>> {
return mockDelay({
items: mockWarehouses,
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
})
list(params: ListWarehousesParams = {}): Promise<PagedResponse<Warehouse>> {
return apiRequest<PagedResponse<Warehouse>>(`/warehouses${buildQuery(params)}`)
},
get(warehouseId: number): Promise<Warehouse> {
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
return mockDelay(wh)
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`)
},
create(request: CreateWarehouseRequest): Promise<Warehouse> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Warehouse code is required."))
if (mockWarehouses.some((w) => w.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Warehouse code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const warehouse: Warehouse = { warehouseId: allocateWarehouseId(), code, name: request.name.trim() }
mockWarehouses.push(warehouse)
return mockDelay(warehouse)
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
},
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
/** Returns a bare array, not a paged envelope — the server does not page bins. */
listBins(warehouseId: number): Promise<Bin[]> {
return apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
},
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Bin code is required."))
if (mockBins.some((b) => b.warehouseId === warehouseId && b.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Bin code "${code}" already exists in this warehouse.`), { code: "SKU_DUPLICATE" }))
}
const bin: Bin = { binId: allocateBinId(), warehouseId, code, binType: request.binType?.trim() || null }
mockBins.push(bin)
return mockDelay(bin)
return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
},
}
+73 -49
View File
@@ -1,20 +1,29 @@
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the
// SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock
// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed
// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry
// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi
// and the shared mock Stock Core, filtered to loss-type reason codes and
// flattened to per-line records for a focused "record wastage" flow and report.
// No new backend concept, no new mock store.
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the SRS —
// stock write-offs (damage, theft/loss, expiry) are modelled as Stock Adjustments with a
// mandatory reason code (FR-STK-07), and the seeded reason codes (docs/10 §B.8.3) already
// include Damage / Theft-Loss / Expiry Write-off. This module is a frontend-only lens over
// stockAdjustmentsApi: filtered to loss-type reasons and flattened to per-line records for
// a focused "record wastage" flow and report. No new backend concept.
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data"
import { stockApi } from "@/lib/api/stock"
import { StockAdjustment } from "@/types/stock"
/** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */
const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"])
/**
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
* Infra/Persistence/DataSeeder.cs — DMG/THEFT/EXP. (`VAR` count-variance and `SYS`
* corrections are adjustments too, but they are not losses, so they stay out.)
*/
const WASTAGE_CODES = new Set(["DMG", "THEFT", "EXP"])
export function wastageReasonCodeIds(): number[] {
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
/** Sync predicate for screens that already hold the reason-code list. */
export function isWastageReasonCode(code: string): boolean {
return WASTAGE_CODES.has(code)
}
export async function wastageReasonCodeIds(): Promise<number[]> {
const res = await reasonCodesApi.list("Adjustment", { pageSize: 200 })
return res.items.filter((r) => isWastageReasonCode(r.code)).map((r) => r.reasonCodeId)
}
export interface WastageRecord {
@@ -28,13 +37,15 @@ export interface WastageRecord {
reasonCodeId: number
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
qty: number
/** FIFO cost of the wasted quantity, summed from the matching outbound ledger entries. */
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
value: number
}
export interface ListWastageParams {
warehouseId?: number
reasonCodeId?: number
page?: number
pageSize?: number
}
export interface RecordWastageInput {
@@ -46,45 +57,58 @@ export interface RecordWastageInput {
}
export const wastageApi = {
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(wastageReasonCodeIds())
const records: WastageRecord[] = []
/**
* Flattens loss-type adjustments to per-item records.
*
* Costs come from the ledger rather than being recomputed here: FIFO consumption is the
* server's job, and the browser cannot see the layers (docs/20 §3.3). One ledger call per
* adjustment is the price of the ledger's polymorphic source reference — acceptable
* because the adjustment page is already bounded.
*/
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(await wastageReasonCodeIds())
if (wastageIds.size === 0) return []
for (const adj of mockStockAdjustments) {
if (!wastageIds.has(adj.reasonCodeId)) continue
if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue
if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue
// The API filters by a single reasonCodeId, so a specific pick can be pushed down;
// otherwise fetch the page and keep the loss-type rows.
const res = await stockAdjustmentsApi.list({
warehouseId: params.warehouseId,
reasonCodeId: params.reasonCodeId,
page: params.page,
pageSize: params.pageSize ?? 50,
})
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
for (const line of adj.lines) {
if (line.qtyDelta >= 0) continue // wastage is always a decrease
const detailed = await Promise.all(
summaries.map(async (summary) => {
const [adjustment, ledger] = await Promise.all([
stockAdjustmentsApi.get(summary.adjustmentId),
stockApi.ledger({ sourceDocType: "ADJ", sourceDocId: summary.adjustmentId, pageSize: 200 }),
])
const value = mockStockLedger
.filter(
(l) =>
l.sourceDocType === "Adjustment" &&
l.sourceDocId === adj.adjustmentId &&
l.itemId === line.itemId &&
l.direction === "Out"
)
.reduce((sum, l) => sum + l.value, 0)
return adjustment.lines
.filter((line) => line.qtyDelta < 0) // wastage is always a decrease
.map<WastageRecord>((line) => {
const value = ledger.items
.filter((l) => l.itemId === line.itemId && l.direction === "Out")
.reduce((sum, l) => sum + l.value, 0)
return {
adjustmentId: adjustment.adjustmentId,
adjLineId: line.adjLineId,
docNo: adjustment.docNo,
createdAt: adjustment.createdAt,
warehouseId: adjustment.warehouseId,
itemId: line.itemId,
binId: line.binId,
reasonCodeId: adjustment.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
}
})
}),
)
records.push({
adjustmentId: adj.adjustmentId,
adjLineId: line.adjLineId,
docNo: adj.docNo,
createdAt: adj.createdAt,
warehouseId: adj.warehouseId,
itemId: line.itemId,
binId: line.binId,
reasonCodeId: adj.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
})
}
}
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
return mockDelay(records)
return detailed.flat().sort((a, b) => b.adjustmentId - a.adjustmentId)
},
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
+44
View File
@@ -0,0 +1,44 @@
// Client-side cache of the signed-in user's PROFILE, for display only.
//
// This is not an auth mechanism and holds no credentials: the session is the httpOnly
// `erp_at` cookie, which JS cannot read and which the API validates on every call. This
// exists only because there is no `GET /auth/me` endpoint — the user object arrives once,
// in the login/register response (docs/11 §2.0) — and the Header needs a name to show.
//
// Treat it as untrusted display data. Clearing it does not log anyone out; only the
// server clearing the cookie does that.
import { AuthUser } from "@/types/auth"
const KEY = "erpcore.user"
export function setStoredUser(user: AuthUser | null): void {
if (typeof window === "undefined") return
if (!user) {
window.localStorage.removeItem(KEY)
return
}
window.localStorage.setItem(KEY, JSON.stringify(user))
}
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthUser
} catch {
// Corrupt/legacy value — drop it rather than crash the shell.
window.localStorage.removeItem(KEY)
return null
}
}
export function clearStoredUser(): void {
setStoredUser(null)
}
/** Best display name available, falling back through the fields AuthHex may leave null. */
export function displayName(user: AuthUser | null): string {
if (!user) return "Signed in"
return user.fullname?.trim() || user.userName?.trim() || user.email?.trim() || "Signed in"
}
+11 -1
View File
@@ -8,6 +8,14 @@ interface ApiErrorLike {
errors?: Record<string, string[]>
}
/**
* Generic framework codes. Unlike the domain codes below, these say nothing on their own —
* the server's `detail` ("A brand named 'bosch' already exists.") is always more useful
* than "This action conflicts…", so for these the detail wins and the text here is only a
* last resort.
*/
const GENERIC_CODES = new Set(["validation_error", "not_found", "conflict"])
const CODE_MESSAGES: Record<string, string> = {
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
STOCK_NEGATIVE_BLOCKED: "Not enough available stock for this action.",
@@ -28,8 +36,10 @@ const CODE_MESSAGES: Record<string, string> = {
export function errorMessage(error: unknown): string {
if (error && typeof error === "object") {
const e = error as ApiErrorLike
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
// A specific domain code beats the server's prose; a generic one loses to it.
if (e.code && !GENERIC_CODES.has(e.code) && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
if (e.detail) return e.detail
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
}
if (error instanceof Error) return error.message
return "Something went wrong."
+7 -10
View File
@@ -1,19 +1,16 @@
import { z } from "zod"
// Treats null/undefined as missing so validation surfaces one clear
// "required" message instead of a generic type error.
// Plain z.string(), not z.preprocess(): preprocess widens the schema's INPUT type to
// `unknown`, so zodResolver produced a Resolver<{email: unknown, …}> that could not be
// assigned to useForm<LoginValues> — the long-standing type error on the login page.
// Form fields always yield strings (RHF defaults them to ""), so the null/undefined
// coercion it was guarding against cannot occur here.
function requiredString(message: string) {
return z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, message)
)
return z.string().min(1, message)
}
// Email validation schema
export const emailSchema = z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, "Email is required").email("Enter a valid email")
)
export const emailSchema = z.string().min(1, "Email is required").email("Enter a valid email")
// Login schema (email + password) for reuse across the app
export const loginSchema = z.object({
@@ -57,9 +57,10 @@ export function validateBrandName(name: string): Record<string, string> {
return errors
}
export function validateVariantCategoryName(name: string): Record<string, string> {
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
export function validateItemTypeName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Category name is required"
if (!name.trim()) errors.name = "Item type name is required"
return errors
}
+27
View File
@@ -0,0 +1,27 @@
import type { NextConfig } from "next"
import path from "path"
// The backend is proxied rather than called cross-origin. This makes /api/* same-origin,
// which means (a) no CORS config is needed on ERPCore, and (b) its Secure/SameSite=Strict
// session cookies just work. BACKEND_ORIGIN is server-side only — deliberately not
// NEXT_PUBLIC_*, since the browser only ever talks to this Next server.
//
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject.
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224"
const nextConfig: NextConfig = {
turbopack: {
root: path.join(__dirname),
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${BACKEND_ORIGIN}/api/:path*`,
},
]
},
}
export default nextConfig
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from "next/server"
// Route guard for the dashboard. (Next 16 renamed the `middleware` convention to `proxy`;
// this file is the same guard under the current name.)
//
// This is a PRESENCE CHECK ONLY, and deliberately so: `erp_at` is httpOnly and holds an
// RS256 JWT that only ERPCore can validate (docs/10 A.4), so the edge cannot tell whether
// it is real, expired, or forged. It exists purely so a logged-out user lands on /login
// instead of a dashboard full of 401s.
//
// The API remains the sole authority (docs/20-FRONTEND.md §3): every screen's data comes
// from calls that enforce auth server-side, and the client redirects to /login on a 401.
const ACCESS_COOKIE = "erp_at"
export default function proxy(request: NextRequest) {
const hasSession = request.cookies.has(ACCESS_COOKIE)
if (hasSession) return NextResponse.next()
const loginUrl = new URL("/login", request.url)
// Preserve where they were headed so login can send them back.
const { pathname, search } = request.nextUrl
if (pathname !== "/dashboard") loginUrl.searchParams.set("next", `${pathname}${search}`)
return NextResponse.redirect(loginUrl)
}
export const config = {
matcher: ["/dashboard/:path*"],
}
+46
View File
@@ -0,0 +1,46 @@
// Auth DTOs (docs/11-BACKEND-PHASE1.md §2.0 — ERPCore's proxy over the AuthHex IdP).
//
// Session-issuing responses carry NO tokens: the access/refresh tokens are delivered only
// as httpOnly cookies (erp_at / erp_rt). The body is user + expiry, nothing else.
/** AuthHex user projection. Note `fullname` is one word here (it is `fullName` on update). */
export interface AuthUser {
userId: string | null
roleId: string | null
userTypeId: string | null
fullname: string | null
userName: string | null
nic: string | null
email: string | null
mobileNumber: string | null
emailVerified: boolean | null
mobileNumberVerified: boolean | null
isActive: boolean | null
isLocked: boolean | null
}
export interface AuthSession {
user: AuthUser | null
/** Seconds until the access cookie expires. */
expiresIn: number
}
export interface LoginRequest {
identifier: string
password: string
/** Optional AuthHex user-type filter; the login screen does not send one. */
userTypeId?: string | null
deviceName?: string | null
}
export interface RegisterRequest {
roleId: string
userTypeId: string
fullname?: string | null
userName?: string | null
nic?: string | null
email?: string | null
mobileNumber?: string | null
password?: string | null
deviceName?: string | null
}
+18 -8
View File
@@ -1,13 +1,21 @@
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
//
// Deviation from the documented contract (recorded in Frontend/PROGRESS.md):
// the doc only specifies POST /grns, POST /grns/{id}/confirm and
// POST /grns/{id}/lines/{id}/release. A usable list/detail screen needs
// GET /grns and GET /grns/{id} — these are modelled here as a planned,
// not-yet-built backend extension (see Backend/PROGRESS.md when it lands).
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for
// Serial-tracked items, but the backend's CreateGrnLineInput has no such field (only
// `batch`). There is therefore deliberately no `serialNumbers` here: typing it would
// invite the UI to send data the server silently drops. Tracked in Frontend/PROGRESS.md.
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
export type HoldStatus = "OnHold" | "Available" | "Rejected"
export type PurchaseOrderStatus =
| "Draft"
| "PendingApproval"
| "Approved"
| "PartiallyReceived"
| "FullyReceived"
| "Closed"
| "Cancelled"
export interface BatchInput {
batchNo: string
@@ -24,8 +32,6 @@ export interface CreateGrnLineInput {
unitCost: number
holdStatus: HoldStatus
batch?: BatchInput | null
/** FR-GRN-04 — required when the item's trackingMode is "Serial"; length must equal qty. */
serialNumbers?: string[] | null
}
export interface CreateGrnRequest {
@@ -58,6 +64,7 @@ export interface Grn {
status: GrnStatus
createdBy: number
createdAt: string
postedAt: string | null
lines: GrnLine[]
}
@@ -68,7 +75,10 @@ export interface GrnSummary {
vendorId: number
warehouseId: number
status: GrnStatus
createdBy: number
createdAt: string
postedAt: string | null
lineCount: number
}
/** docs/11 §4.2 confirm response. */
@@ -89,7 +99,7 @@ export interface ConfirmGrnResponse {
postedAt: string
createdLayers: CreatedLayer[]
ledgerRefs: number[]
poStatus: string | null
poStatus: PurchaseOrderStatus | null
}
export type ReleaseAction = "Release" | "Reject"
+107 -28
View File
@@ -1,7 +1,13 @@
// Master Data DTOs (docs/11-BACKEND-PHASE1.md §2; FR-MD-01..08).
// Master Data DTOs (docs/11-BACKEND-PHASE1.md §2; FR-MD-01..11).
// Mirrors the backend DTOs exactly — the API contract is the source of truth (docs/20 §1).
import { EntityStatus } from "@/types/common"
export type ItemType = "Stocked" | "NonStocked" | "Service"
/**
* Whether an item holds stock. Renamed from `ItemType` on 2026-07-16 (docs/11 §8): the
* name `ItemType` now means a *dimension master* (Color/Size), which is a different
* concept entirely — see {@link ItemType} below.
*/
export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export interface ItemListItem {
@@ -9,10 +15,11 @@ export interface ItemListItem {
sku: string
name: string
categoryId: number
brandId?: number | null
subCategoryId: number | null
brandId: number | null
baseUomId: number
defaultVendorId: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass: string | null
status: EntityStatus
@@ -32,14 +39,11 @@ export interface UomConversion {
}
/**
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). `conversions` is a
* deviation: the documented example response only shows `reorder` inline, and
* conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions`
* (which returns them but has no matching GET). Embedding them here — like the
* assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app — lets the Item
* detail screen show current conversions before editing; flag this to whoever
* implements the real endpoint (docs/10 §4 already lists UOMConversion as its own
* entity, so the full resource plausibly carries it).
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
*
* Note there is no item-type field: the values chosen in the builder are encoded into the
* client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the
* only record of which colour/size an item is.
*/
export interface Item {
itemId: number
@@ -47,33 +51,36 @@ export interface Item {
name: string
description: string | null
categoryId: number
brandId?: number | null
/** Optional second level; must belong to `categoryId` or the server returns 422. */
subCategoryId: number | null
brandId: number | null
baseUomId: number
defaultVendorId: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass: string | null
status: EntityStatus
reorder: ItemReorderSetting[]
conversions: UomConversion[]
/** Quantity captured at creation time (e.g. from the variant builder). Not wired into the Stock Core ledger — informational only. */
initialQty?: number | null
createdAt: string
updatedAt: string | null
}
export interface CreateItemRequest {
/** Client-generated; the server only enforces uniqueness (`SKU_DUPLICATE`). */
sku: string
name: string
description?: string | null
categoryId: number
/** Rejected with `CONFIG_DISABLED` when subcategories are switched off (docs/11 §2.8). */
subCategoryId?: number | null
/** Rejected with `CONFIG_DISABLED` when brands are switched off. */
brandId?: number | null
baseUomId: number
defaultVendorId?: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass?: string | null
initialQty?: number | null
}
export type UpdateItemRequest = CreateItemRequest
@@ -98,6 +105,11 @@ export interface Warehouse {
name: string
}
export interface CreateWarehouseRequest {
code: string
name: string
}
export interface Bin {
binId: number
warehouseId: number
@@ -105,6 +117,11 @@ export interface Bin {
binType: string | null
}
export interface CreateBinRequest {
code: string
binType?: string | null
}
export interface Vendor {
vendorId: number
code: string
@@ -117,6 +134,16 @@ export interface Vendor {
updatedAt: string | null
}
export interface CreateVendorRequest {
code: string
name: string
terms?: string | null
taxReg?: string | null
currency: string
}
export type UpdateVendorRequest = CreateVendorRequest
export interface Uom {
uomId: number
name: string
@@ -126,30 +153,49 @@ export interface CreateUomRequest {
name: string
}
// Category / SubCategory — a fixed two-level hierarchy. Categories no longer self-nest:
// `parentId` and `GET /categories?tree=true` were removed on 2026-07-16 (docs/11 §2.3).
export interface Category {
categoryId: number
name: string
parentId: number | null
status: EntityStatus
createdAt: string
}
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[]
updatedAt: string | null
}
export interface CreateCategoryRequest {
name: string
parentId?: number | null
}
export interface UpdateCategoryRequest {
name: string
}
export interface SubCategory {
subCategoryId: number
categoryId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateSubCategoryRequest {
name: string
}
/** Name only — a subcategory cannot be reparented (docs/11 §2.3). */
export interface UpdateSubCategoryRequest {
name: string
}
export interface Brand {
brandId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateBrandRequest {
@@ -160,16 +206,49 @@ export interface UpdateBrandRequest {
name: string
}
export interface VariantCategory {
variantCategoryId: number
/**
* Item type master (docs/11 §2.7) — a dimension *name* such as Color, Size or Material.
* Formerly `VariantCategory` in this app.
*
* Nothing links an item to one of these: it exists only to populate the builder's
* dropdown. The chosen values are baked into the SKU client-side. Not to be confused with
* {@link StockNature}, which is what the old `itemType` enum became.
*/
export interface ItemType {
itemTypeId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateVariantCategoryRequest {
export interface CreateItemTypeRequest {
name: string
}
export interface UpdateVariantCategoryRequest {
export interface UpdateItemTypeRequest {
name: string
}
/**
* Product configuration (docs/11 §2.8) — singleton feature gate.
*
* `subcategoriesEnabled`/`brandsEnabled` are enforced server-side (`CONFIG_DISABLED` on
* item writes). `itemTypesEnabled` is **advisory**: items carry no item-type reference, so
* there is nothing for the server to reject — the frontend is what honours it by hiding
* the builder's type section.
*/
export interface ProductConfig {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
/** All three flags are required: an omitted flag is a 400, never a silent disable. */
export interface UpdateProductConfigRequest {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
}
+29 -22
View File
@@ -1,6 +1,5 @@
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the
// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see
// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock.
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09).
// Mirrors the live Dtos/Procurement/*.cs — the API contract is the source of truth.
// --- 3.1 Requisitions --------------------------------------------------------------
@@ -10,7 +9,8 @@ export interface ReqLine {
reqLineId: number
itemId: number
qty: number
requiredBy: string
/** Date only, `YYYY-MM-DD`. */
requiredBy: string | null
}
export interface Requisition {
@@ -34,7 +34,7 @@ export interface RequisitionSummary {
export interface CreateReqLineInput {
itemId: number
qty: number
requiredBy: string
requiredBy?: string | null
}
export interface CreateRequisitionRequest {
@@ -43,8 +43,6 @@ export interface CreateRequisitionRequest {
// --- 3.2 RFQs & Quotations ----------------------------------------------------------
/** Phase 1 only documents "Open" on creation; "Closed" is a frontend-only convenience
* applied once a PO is created from the RFQ (see Frontend/PROGRESS.md §3 deviation note). */
export type RfqStatus = "Open" | "Closed"
export interface RfqLine {
@@ -53,23 +51,26 @@ export interface RfqLine {
qty: number
}
/**
* Note: the server returns no `vendorIds` or `createdAt` on an RFQ. The invited-vendor
* list is validated on create but not persisted (no RFQ↔vendor link in the model);
* quotations reference vendors directly.
*/
export interface Rfq {
rfqId: number
docNo: string
requisitionId: number | null
requisitionId: number
status: RfqStatus
vendorIds: number[]
createdAt: string
lines: RfqLine[]
}
export interface RfqSummary {
rfqId: number
docNo: string
requisitionId: number | null
requisitionId: number
status: RfqStatus
vendorIds: number[]
createdAt: string
lineCount: number
quotationCount: number
}
export interface CreateRfqLineInput {
@@ -78,7 +79,8 @@ export interface CreateRfqLineInput {
}
export interface CreateRfqRequest {
requisitionId?: number | null
/** Required — an RFQ is always raised against a requisition. */
requisitionId: number
vendorIds: number[]
lines: CreateRfqLineInput[]
}
@@ -93,7 +95,6 @@ export interface Quotation {
quotationId: number
rfqId: number
vendorId: number
createdAt: string
lines: QuotationLine[]
}
@@ -104,20 +105,21 @@ export interface CreateQuotationRequest {
export interface RfqComparisonCell {
vendorId: number
quotationId: number
unitPrice: number
leadDays: number
}
export interface RfqComparisonLine {
export interface RfqComparisonRow {
itemId: number
qty: number
cells: RfqComparisonCell[]
quotes: RfqComparisonCell[]
}
export interface RfqComparison {
rfqId: number
vendorIds: number[]
lines: RfqComparisonLine[]
rows: RfqComparisonRow[]
}
// --- 3.3 Purchase Orders -------------------------------------------------------------
@@ -138,6 +140,7 @@ export interface PoLine {
warehouseId: number
qty: number
unitPrice: number
/** Rate, 0..1 (e.g. 0.18), not an amount. */
tax: number
qtyReceived: number
}
@@ -192,16 +195,17 @@ export interface CreatePurchaseOrderRequest {
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
export interface CancelPurchaseOrderRequest {
reason: string
reason?: string | null
}
// --- 3.4 Purchase Returns -------------------------------------------------------------
export type PurchaseReturnStatus = "Posted"
export type PurchaseReturnStatus = "Draft" | "Posted"
export interface PurchaseReturnLine {
returnLineId: number
grnLineId: number
/** Optional: a return may reference the originating GRN line for traceability. */
grnLineId: number | null
itemId: number
qty: number
}
@@ -226,11 +230,13 @@ export interface PurchaseReturnSummary {
warehouseId: number
reasonCodeId: number
status: PurchaseReturnStatus
createdBy: number
createdAt: string
lineCount: number
}
export interface CreatePurchaseReturnLineInput {
grnLineId: number
grnLineId?: number | null
itemId: number
qty: number
}
@@ -238,6 +244,7 @@ export interface CreatePurchaseReturnLineInput {
export interface CreatePurchaseReturnRequest {
vendorId: number
warehouseId: number
/** Mandatory (FR-PROC-08); omitting it returns 400 REASON_CODE_REQUIRED. */
reasonCodeId: number
lines: CreatePurchaseReturnLineInput[]
}

Some files were not shown because too many files have changed in this diff Show More