Complete all for Items
This commit is contained in:
@@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase
|
|||||||
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), 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")]
|
[HttpPost("logout")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[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);
|
AuthCookieWriter.ClearSession(Response);
|
||||||
return NoContent();
|
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")]
|
[HttpPut("me")]
|
||||||
[ValidateCsrf]
|
[ValidateCsrf]
|
||||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Grn;
|
using ERPCore.Dtos.Grn;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
|
|||||||
|
|
||||||
public GrnsController(IGrnService grns) => _grns = grns;
|
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}")]
|
[HttpGet("{grnId:int}")]
|
||||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
|
|||||||
|
|
||||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
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>
|
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||||
=> Ok(await _requisitions.ListAsync(query, ct));
|
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||||
|
|
||||||
[HttpGet("{requisitionId:int}")]
|
[HttpGet("{requisitionId:int}")]
|
||||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
|
|||||||
|
|
||||||
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
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}")]
|
[HttpGet("{rfqId:int}")]
|
||||||
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
|
|
||||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
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>
|
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||||
|
|||||||
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
|
|||||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, 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")]
|
[HttpGet("ledger")]
|
||||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
[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")]
|
[HttpGet("valuation")]
|
||||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
|
|||||||
|
|
||||||
public StockCountsController(ICountService counts) => _counts = counts;
|
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}")]
|
[HttpGet("{countId:int}")]
|
||||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
|
|||||||
|
|
||||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
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}")]
|
[HttpGet("{transferId:int}")]
|
||||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse
|
|||||||
|
|
||||||
public sealed class LogoutRequest
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ public sealed record GrnDto(
|
|||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
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(
|
public sealed record CreatedLayerDto(
|
||||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||||
|
|||||||
@@ -14,12 +14,21 @@ public sealed record ItemListItemDto(
|
|||||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
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(
|
public sealed record ItemDetailDto(
|
||||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||||
StockNature StockNature, TrackingMode TrackingMode,
|
StockNature StockNature, TrackingMode TrackingMode,
|
||||||
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||||
|
IReadOnlyList<UomConversionDto> Conversions,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
/// <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(
|
public sealed record PurchaseReturnDto(
|
||||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
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 ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
|
|||||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||||
|
|
||||||
public sealed record RequisitionSummaryDto(
|
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 ----
|
// 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(
|
public sealed record RfqDto(
|
||||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
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 QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||||
|
|
||||||
public sealed record VendorQuotationDto(
|
public sealed record VendorQuotationDto(
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
|
|||||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
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 ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
public sealed class CreateAdjustmentLineInput
|
public sealed class CreateAdjustmentLineInput
|
||||||
|
|||||||
@@ -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 CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||||
|
|
||||||
public sealed record CountDto(
|
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);
|
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
|
|||||||
|
|
||||||
public sealed record TransferDto(
|
public sealed record TransferDto(
|
||||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
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);
|
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IStockMutator _mutator;
|
private readonly IStockMutator _mutator;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
|
|
||||||
public AdjustmentService(
|
public AdjustmentService(
|
||||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||||
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
|
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow)
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_items = items;
|
_items = items;
|
||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
|
_ledger = ledger;
|
||||||
_mutator = mutator;
|
_mutator = mutator;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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)
|
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ReasonCodeId is null)
|
if (request.ReasonCodeId is null)
|
||||||
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
return (entity, refs);
|
return (entity, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
return new AdjustmentDto(
|
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
|
||||||
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
|
=> _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)
|
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)
|
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
|
||||||
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
|
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(
|
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)
|
c.Lines.OrderBy(l => l.CountLineId)
|
||||||
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Grn;
|
using ERPCore.Dtos.Grn;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var grn = await _grns.Query().AsNoTracking()
|
var grn = await _grns.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
|
||||||
public interface IAdjustmentService
|
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);
|
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
|
||||||
public interface ICountService
|
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?> GetAsync(int countId, CancellationToken ct = default);
|
||||||
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
||||||
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest 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;
|
using ERPCore.Dtos.Grn;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
|
||||||
public interface IGrnService
|
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?> GetAsync(int grnId, CancellationToken ct = default);
|
||||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
|
||||||
public interface IPurchaseReturnService
|
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);
|
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
@@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
|
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
|
||||||
public interface IRequisitionService
|
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?> GetAsync(int requisitionId, CancellationToken ct = default);
|
||||||
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
|
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
|
||||||
Task<RequisitionDto> SubmitAsync(int requisitionId, 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;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
@@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>RFQ & vendor-quotation business logic (docs/11 §3.2).</summary>
|
/// <summary>RFQ & vendor-quotation business logic (docs/11 §3.2).</summary>
|
||||||
public interface IRfqService
|
public interface IRfqService
|
||||||
{
|
{
|
||||||
|
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
|
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
|
||||||
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
|
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
|
||||||
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest 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);
|
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(
|
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);
|
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;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
|
||||||
public interface ITransferService
|
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?> GetAsync(int transferId, CancellationToken ct = default);
|
||||||
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ public sealed class ItemService : IItemService
|
|||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
.Include(i => i.ReorderSettings)
|
.Include(i => i.ReorderSettings)
|
||||||
|
.Include(i => i.UomConversions)
|
||||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||||
|
|
||||||
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
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()
|
var item = await _items.Query()
|
||||||
.Include(i => i.ReorderSettings)
|
.Include(i => i.ReorderSettings)
|
||||||
|
.Include(i => i.UomConversions)
|
||||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||||
|
|
||||||
@@ -351,5 +353,9 @@ public sealed class ItemService : IItemService
|
|||||||
.OrderBy(r => r.WarehouseId)
|
.OrderBy(r => r.WarehouseId)
|
||||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
i.UomConversions
|
||||||
|
.OrderBy(c => c.ConversionId)
|
||||||
|
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||||
|
.ToList(),
|
||||||
i.CreatedAt, i.UpdatedAt);
|
i.CreatedAt, i.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
private readonly IRepository<GrnLine> _grnLines;
|
private readonly IRepository<GrnLine> _grnLines;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IStockMutator _mutator;
|
private readonly IStockMutator _mutator;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
public PurchaseReturnService(
|
public PurchaseReturnService(
|
||||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
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;
|
_returns = returns;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
@@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
_grnLines = grnLines;
|
_grnLines = grnLines;
|
||||||
|
_ledger = ledger;
|
||||||
_mutator = mutator;
|
_mutator = mutator;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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)
|
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ReasonCodeId is null)
|
if (request.ReasonCodeId is null)
|
||||||
@@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// Map ledger ids after commit so they are populated.
|
// Map ledger ids after commit so they are populated.
|
||||||
return new PurchaseReturnDto(
|
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
_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();
|
var q = _requisitions.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
|
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 total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(r => r.RequisitionId)
|
var rows = await q.OrderByDescending(r => r.RequisitionId)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
@@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rfq = await _rfqs.Query().AsNoTracking()
|
var rfq = await _rfqs.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -50,14 +50,85 @@ public sealed class StockService : IStockService
|
|||||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
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(
|
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();
|
var q = _ledger.Query().AsNoTracking();
|
||||||
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
|
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 (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 (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));
|
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 total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(l => l.LedgerId)
|
var rows = await q.OrderByDescending(l => l.LedgerId)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
|
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(
|
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(
|
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());
|
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -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.)
|
- [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.
|
> **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
|
## 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.
|
> 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.
|
- **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.
|
- **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.
|
- **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.
|
- **✅ 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).
|
||||||
- **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`.
|
- **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.
|
||||||
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).
|
- **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 56–65, 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.
|
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 56–65, 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.
|
- **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
@@ -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.
|
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
|
## 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)
|
- [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.)
|
||||||
- [ ] 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] **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] 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] **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)
|
- [~] 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
|
- [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.
|
||||||
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
|
> **⚠️ 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).
|
> **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.
|
> **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
|
## 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 — 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 — 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
|
- [~] 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`
|
- [~] 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`
|
- 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`).
|
> **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).
|
- [~] 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`)
|
- 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).
|
> **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
|
## Done
|
||||||
<!-- move [x] items here with date + note if the active list grows long -->
|
<!-- 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)
|
### 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.
|
- 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.
|
- 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.
|
- **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).
|
- 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.
|
- 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.
|
- 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)
|
### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,6 +32,8 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
# ...except the template, which carries no secrets and documents what to set.
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
|
|||||||
setVendorId(rfqVendorId)
|
setVendorId(rfqVendorId)
|
||||||
setLines(
|
setLines(
|
||||||
rfq.lines.map((l): DraftLine => {
|
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 {
|
return {
|
||||||
key: newKey(),
|
key: newKey(),
|
||||||
itemId: l.itemId,
|
itemId: l.itemId,
|
||||||
|
|||||||
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
|
|||||||
|
|
||||||
const quotedVendorIds = useMemo(() => {
|
const quotedVendorIds = useMemo(() => {
|
||||||
const set = new Set<number>()
|
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
|
return set
|
||||||
}, [comparison])
|
}, [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) {
|
function itemFor(itemId: number) {
|
||||||
return items.find((i) => i.itemId === itemId)
|
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>
|
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
|
||||||
<RfqStatusBadge status={rfq.status} />
|
<RfqStatusBadge status={rfq.status} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""}
|
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
|
||||||
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
|
<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>
|
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
<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>
|
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{comparison.lines.map((line) => {
|
{comparison.rows.map((line) => {
|
||||||
const item = itemFor(line.itemId)
|
const item = itemFor(line.itemId)
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.itemId}>
|
<TableRow key={line.itemId}>
|
||||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||||
{rfq.vendorIds.map((vid) => {
|
{comparison.vendorIds.map((vid) => {
|
||||||
const cell = line.cells.find((c) => c.vendorId === vid)
|
const cell = line.quotes.find((c) => c.vendorId === vid)
|
||||||
return (
|
return (
|
||||||
<TableCell key={vid} className="px-3 py-3.5">
|
<TableCell key={vid} className="px-3 py-3.5">
|
||||||
{cell ? (
|
{cell ? (
|
||||||
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
|
|||||||
<Label className="text-base">Vendor</Label>
|
<Label className="text-base">Vendor</Label>
|
||||||
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
<SelectValue placeholder="Select an invited vendor" />
|
<SelectValue placeholder="Select a vendor" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pendingVendors.map((vid) => (
|
{pendingVendors.map((vid) => (
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ function NewRfqContent() {
|
|||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
setSubmitError(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) {
|
if (vendorIds.size === 0) {
|
||||||
setHeaderError("Select at least one vendor to invite.")
|
setHeaderError("Select at least one vendor to invite.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import Link from "next/link"
|
|||||||
import { FileText, Plus } from "lucide-react"
|
import { FileText, Plus } from "lucide-react"
|
||||||
|
|
||||||
import { rfqsApi } from "@/lib/api/rfqs"
|
import { rfqsApi } from "@/lib/api/rfqs"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { RfqSummary } from "@/types/procurement"
|
import { RfqSummary } from "@/types/procurement"
|
||||||
import { Vendor } from "@/types/master-data"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
export default function RfqsListPage() {
|
export default function RfqsListPage() {
|
||||||
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
|
rfqsApi
|
||||||
.then(([r, v]) => {
|
.list()
|
||||||
setRfqs(r.items)
|
.then((r) => setRfqs(r.items))
|
||||||
setVendors(v.items)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function vendorNames(vendorIds: number[]) {
|
|
||||||
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
<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">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">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</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">{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">
|
<TableCell className="px-3 py-3.5">
|
||||||
<RfqStatusBadge status={r.status} />
|
<RfqStatusBadge status={r.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
|
|||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
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 [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||||
const [defaultVendorId, setDefaultVendorId] = 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 [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||||
const [taxClass, setTaxClass] = useState("")
|
const [taxClass, setTaxClass] = useState("")
|
||||||
|
|
||||||
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
|
|||||||
setName(data.name)
|
setName(data.name)
|
||||||
setDescription(data.description ?? "")
|
setDescription(data.description ?? "")
|
||||||
setCategoryId(data.categoryId)
|
setCategoryId(data.categoryId)
|
||||||
|
setSubCategoryId(data.subCategoryId)
|
||||||
|
setBrandId(data.brandId)
|
||||||
setBaseUomId(data.baseUomId)
|
setBaseUomId(data.baseUomId)
|
||||||
setDefaultVendorId(data.defaultVendorId)
|
setDefaultVendorId(data.defaultVendorId)
|
||||||
setItemType(data.itemType)
|
setStockNature(data.stockNature)
|
||||||
setTrackingMode(data.trackingMode)
|
setTrackingMode(data.trackingMode)
|
||||||
setTaxClass(data.taxClass ?? "")
|
setTaxClass(data.taxClass ?? "")
|
||||||
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
|
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 {
|
try {
|
||||||
const result = await itemsApi.update(
|
const result = await itemsApi.update(
|
||||||
item.itemId,
|
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
|
etag
|
||||||
)
|
)
|
||||||
applyItem(result.data)
|
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} />
|
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Item type</Label>
|
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||||
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
|
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">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { brandsApi } from "@/lib/api/brands"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
|
|||||||
import { Brand } from "@/types/master-data"
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -54,7 +55,7 @@ export default function BrandsPage() {
|
|||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
brandsApi
|
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) => {
|
.then((res) => {
|
||||||
setBrands(res.items)
|
setBrands(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -87,10 +88,16 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const brand = editing
|
let result
|
||||||
? await brandsApi.update(editing.brandId, { name })
|
if (editing) {
|
||||||
: await brandsApi.create({ name })
|
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||||
toast.success(editing ? "Brand updated" : "Brand created", brand.name)
|
// 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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
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)
|
setDeletingId(brand.brandId)
|
||||||
try {
|
try {
|
||||||
await brandsApi.remove(brand.brandId)
|
await brandsApi.updateStatus(brand.brandId, next)
|
||||||
toast.success("Brand deleted", brand.name)
|
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete brand", errorMessage(err))
|
toast.error("Could not update brand status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
@@ -206,6 +215,7 @@ export default function BrandsPage() {
|
|||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -215,6 +225,9 @@ export default function BrandsPage() {
|
|||||||
<TableRow key={b.brandId}>
|
<TableRow key={b.brandId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
<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 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 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -227,26 +240,32 @@ export default function BrandsPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Deactivate, not delete: the API has no DELETE for any master
|
||||||
|
(FR-MD-08) — records referenced by transactions must survive. */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${b.name}`}
|
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
|
||||||
disabled={deletingId === b.brandId}
|
disabled={deletingId === b.brandId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={b.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${b.name}?`}
|
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
|
||||||
description="This permanently removes the brand."
|
description={
|
||||||
confirmLabel="Delete"
|
b.status === "Active"
|
||||||
onConfirm={() => handleDelete(b)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</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 { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { categoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
|
|||||||
import { Category } from "@/types/master-data"
|
import { Category } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
|
|||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
categoriesApi
|
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) => {
|
.then((res) => {
|
||||||
setCategories(res.items)
|
setCategories(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = editing
|
let result
|
||||||
? await categoriesApi.update(editing.categoryId, { name })
|
if (editing) {
|
||||||
: await categoriesApi.create({ name })
|
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
|
||||||
toast.success(editing ? "Category updated" : "Category created", category.name)
|
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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
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)
|
setDeletingId(category.categoryId)
|
||||||
try {
|
try {
|
||||||
await categoriesApi.remove(category.categoryId)
|
await categoriesApi.updateStatus(category.categoryId, next)
|
||||||
toast.success("Category deleted", category.name)
|
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete category", errorMessage(err))
|
toast.error("Could not update category status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
|
|||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
|
|||||||
<TableRow key={c.categoryId}>
|
<TableRow key={c.categoryId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<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 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 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${c.name}`}
|
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||||
disabled={deletingId === c.categoryId}
|
disabled={deletingId === c.categoryId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={c.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${c.name}?`}
|
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||||
description="This permanently removes the category."
|
description={
|
||||||
confirmLabel="Delete"
|
c.status === "Active"
|
||||||
onConfirm={() => handleDelete(c)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-57
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { 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 { 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 { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<VariantCategory | null>(null)
|
const [editing, setEditing] = useState<ItemType | null>(null)
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
variantCategoriesApi
|
itemTypesApi
|
||||||
.list()
|
.list({ pageSize: 200 })
|
||||||
.then((res) => setCategories(res.items))
|
.then((res) => setItemTypes(res.items))
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,24 +56,28 @@ export default function VariantsPage() {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(category: VariantCategory) {
|
function openEditDialog(itemType: ItemType) {
|
||||||
setEditing(category)
|
setEditing(itemType)
|
||||||
setName(category.name)
|
setName(itemType.name)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const nextErrors = validateVariantCategoryName(name)
|
const nextErrors = validateItemTypeName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = editing
|
if (editing) {
|
||||||
? await variantCategoriesApi.update(editing.variantCategoryId, { name })
|
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
|
||||||
: await variantCategoriesApi.create({ name })
|
const current = await itemTypesApi.get(editing.itemTypeId)
|
||||||
toast.success(editing ? "Variant category updated" : "Variant category created", category.name)
|
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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -72,22 +85,24 @@ export default function VariantsPage() {
|
|||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors({ name: errorMessage(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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(category: VariantCategory) {
|
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
|
||||||
setDeletingId(category.variantCategoryId)
|
async function handleToggleStatus(itemType: ItemType) {
|
||||||
|
const next = itemType.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setTogglingId(itemType.itemTypeId)
|
||||||
try {
|
try {
|
||||||
await variantCategoriesApi.remove(category.variantCategoryId)
|
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
|
||||||
toast.success("Variant category deleted", category.name)
|
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete category", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setTogglingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,23 +114,25 @@ export default function VariantsPage() {
|
|||||||
<ArrowLeft className="size-5" />
|
<ArrowLeft className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
|
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||||
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<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">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
|
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
|
||||||
<DialogDescription>Give the category a name.</DialogDescription>
|
<DialogDescription>Give the item type a name.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
|
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="variant-category-name"
|
id="item-type-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Material"
|
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>
|
<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">
|
<div className="flex flex-col gap-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
@@ -148,37 +165,36 @@ export default function VariantsPage() {
|
|||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && categories !== null && categories.length > 0 && (
|
{!error && itemTypes !== null && itemTypes.length > 0 && (
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{categories.map((c) => (
|
{itemTypes.map((t) => (
|
||||||
<TableRow key={c.variantCategoryId}>
|
<TableRow key={t.itemTypeId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</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">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${c.name}`}
|
|
||||||
onClick={() => openEditDialog(c)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -188,20 +204,24 @@ export default function VariantsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${c.name}`}
|
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
|
||||||
disabled={deletingId === c.variantCategoryId}
|
disabled={togglingId === t.itemTypeId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={t.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${c.name}?`}
|
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
|
||||||
description="This permanently removes the variant category."
|
description={
|
||||||
confirmLabel="Delete"
|
t.status === "Active"
|
||||||
onConfirm={() => handleDelete(c)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
|
|||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
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 { 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 { 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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
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("-")
|
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 {
|
function isColorCategory(categoryName: string): boolean {
|
||||||
return categoryName.trim().toLowerCase() === "color"
|
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
|
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() {
|
export default function NewItemPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||||
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | 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 [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
|
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||||
const [brandId, setBrandId] = 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 [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
||||||
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
||||||
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
||||||
const [quantities, setQuantities] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCategoryName, setNewCategoryName] = useState("")
|
const [newCategoryName, setNewCategoryName] = useState("")
|
||||||
@@ -83,23 +94,40 @@ export default function NewItemPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
|
Promise.all([
|
||||||
.then(([cat, br, vc]) => {
|
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)
|
setCategories(cat.items)
|
||||||
setBrands(br.items)
|
setBrands(br.items)
|
||||||
setVariantCategories(vc.items)
|
setItemTypes(types.items)
|
||||||
|
setConfig(cfg)
|
||||||
|
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
|
// Subcategories are their own resource now — fetched per category rather than filtered
|
||||||
const subCategoryOptions = useMemo(
|
// out of a flat list by parentId (that column no longer exists).
|
||||||
() => (categories ?? []).filter((c) => c.parentId === categoryId),
|
useEffect(() => {
|
||||||
[categories, categoryId]
|
if (categoryId === null || !config?.subcategoriesEnabled) {
|
||||||
)
|
setSubCategories([])
|
||||||
const effectiveCategoryId = subCategoryId ?? categoryId
|
return
|
||||||
const effectiveCategoryLabel =
|
}
|
||||||
(categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? ""
|
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 ?? ""
|
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||||
|
|
||||||
function handleCategoryChange(value: number | null) {
|
function handleCategoryChange(value: number | null) {
|
||||||
@@ -107,28 +135,27 @@ export default function NewItemPage() {
|
|||||||
setSubCategoryId(null)
|
setSubCategoryId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVariantCategory(variantCategoryId: number) {
|
function toggleItemType(itemTypeId: number) {
|
||||||
setCheckedVariantCategoryIds((prev) =>
|
setCheckedItemTypeIds((prev) =>
|
||||||
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
|
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
|
||||||
)
|
)
|
||||||
setQuantities({})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddVariantCategory() {
|
async function handleAddItemType() {
|
||||||
const nextErrors = validateVariantCategoryName(newCategoryName)
|
const nextErrors = validateItemTypeName(newCategoryName)
|
||||||
if (nextErrors.name) {
|
if (nextErrors.name) {
|
||||||
setNewCategoryError(nextErrors.name)
|
setNewCategoryError(nextErrors.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAddingCategorySubmitting(true)
|
setAddingCategorySubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = await variantCategoriesApi.create({ name: newCategoryName })
|
const created = await itemTypesApi.create({ name: newCategoryName })
|
||||||
setVariantCategories((prev) => [...(prev ?? []), category])
|
setItemTypes((prev) => [...(prev ?? []), created.data])
|
||||||
setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId])
|
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
|
||||||
setNewCategoryName("")
|
setNewCategoryName("")
|
||||||
setNewCategoryError(null)
|
setNewCategoryError(null)
|
||||||
setAddingCategory(false)
|
setAddingCategory(false)
|
||||||
toast.success("Variant category created", category.name)
|
toast.success("Item type created", created.data.name)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNewCategoryError(errorMessage(err))
|
setNewCategoryError(errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,34 +163,32 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addValue(variantCategoryId: number, overrideValue?: string) {
|
function addValue(itemTypeId: number, overrideValue?: string) {
|
||||||
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
|
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
|
||||||
if (value) {
|
if (value) {
|
||||||
setValuesByCategory((prev) => {
|
setValuesByCategory((prev) => {
|
||||||
const existing = prev[variantCategoryId] ?? []
|
const existing = prev[itemTypeId] ?? []
|
||||||
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
|
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) => ({
|
setValuesByCategory((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
|
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
|
||||||
}))
|
}))
|
||||||
setQuantities({})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCategories = useMemo(
|
const activeCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(variantCategories ?? [])
|
(itemTypes ?? [])
|
||||||
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
|
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||||
.map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] }))
|
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
|
||||||
.filter((vc) => vc.values.length > 0),
|
.filter((t) => t.values.length > 0),
|
||||||
[variantCategories, checkedVariantCategoryIds, valuesByCategory]
|
[itemTypes, checkedItemTypeIds, valuesByCategory]
|
||||||
)
|
)
|
||||||
|
|
||||||
const variants = useMemo(() => {
|
const variants = useMemo(() => {
|
||||||
@@ -183,44 +208,58 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
return combinations.map((c) => ({
|
return combinations.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
|
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
|
||||||
}))
|
}))
|
||||||
}, [activeCategories, effectiveCategoryLabel])
|
}, [activeCategories, effectiveLabel])
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
|
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
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)
|
setSubmitting(true)
|
||||||
try {
|
|
||||||
let created = 0
|
let created = 0
|
||||||
|
try {
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const qty = Number(quantities[variant.key] || 0)
|
|
||||||
await itemsApi.create({
|
await itemsApi.create({
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
||||||
categoryId: effectiveCategoryId as number,
|
// 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,
|
brandId,
|
||||||
baseUomId: DEFAULT_BASE_UOM_ID,
|
baseUomId,
|
||||||
itemType: "Stocked",
|
stockNature: "Stocked",
|
||||||
trackingMode: "None",
|
trackingMode: "None",
|
||||||
initialQty: Number.isFinite(qty) ? qty : 0,
|
|
||||||
})
|
})
|
||||||
created += 1
|
created += 1
|
||||||
}
|
}
|
||||||
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
||||||
router.push("/dashboard/products")
|
router.push("/dashboard/products")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(errorMessage(err))
|
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
||||||
toast.error("Could not create variants", errorMessage(err))
|
// 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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = !categories || !brands || !variantCategories
|
const loading = !categories || !brands || !itemTypes || !config
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -230,7 +269,7 @@ export default function NewItemPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,6 +279,16 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
{loading && !loadError && <Skeleton className="h-64 w-full" />}
|
{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 && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@@ -250,7 +299,7 @@ export default function NewItemPage() {
|
|||||||
<SelectValue placeholder="Select category" />
|
<SelectValue placeholder="Select category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{topCategories.map((c) => (
|
{(categories ?? []).map((c) => (
|
||||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||||
{c.name}
|
{c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -259,21 +308,26 @@ export default function NewItemPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Subcategory (optional)</Label>
|
<Label className="text-base">Subcategory (optional)</Label>
|
||||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
|
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
|
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{subCategoryOptions.map((c) => (
|
{subCategories.map((s) => (
|
||||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
|
||||||
{c.name}
|
{s.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{config?.brandsEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Brand (optional)</Label>
|
<Label className="text-base">Brand (optional)</Label>
|
||||||
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
||||||
@@ -289,24 +343,28 @@ export default function NewItemPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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 className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{(variantCategories ?? []).map((vc) => (
|
{(itemTypes ?? []).map((t) => (
|
||||||
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
|
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||||
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
|
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||||
/>
|
/>
|
||||||
<span className="text-base font-medium">{vc.name}</span>
|
<span className="text-base font-medium">{t.name}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{!addingCategory && (
|
{!addingCategory && (
|
||||||
@@ -314,7 +372,7 @@ export default function NewItemPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="Add another variant category"
|
aria-label="Add another item type"
|
||||||
onClick={() => setAddingCategory(true)}
|
onClick={() => setAddingCategory(true)}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -331,7 +389,7 @@ export default function NewItemPage() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleAddVariantCategory()
|
handleAddItemType()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Material"
|
placeholder="Material"
|
||||||
@@ -339,7 +397,7 @@ export default function NewItemPage() {
|
|||||||
aria-invalid={!!newCategoryError}
|
aria-invalid={!!newCategoryError}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
|
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -363,38 +421,38 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
<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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{(variantCategories ?? [])
|
{(itemTypes ?? [])
|
||||||
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
|
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||||
.map((vc) => {
|
.map((t) => {
|
||||||
const isColor = isColorCategory(vc.name)
|
const isColor = isColorCategory(t.name)
|
||||||
const currentInput = inputByCategory[vc.variantCategoryId] ?? ""
|
const currentInput = inputByCategory[t.itemTypeId] ?? ""
|
||||||
const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? ""
|
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
|
||||||
|
|
||||||
function addColor() {
|
function addColor() {
|
||||||
const name = currentColorName.trim()
|
const name = currentColorName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
|
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
|
||||||
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
|
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
|
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||||
<Label className="text-base">{vc.name} values</Label>
|
<Label className="text-base">{t.name} values</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isColor ? (
|
{isColor ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={currentInput || "#EF4444"}
|
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"
|
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
|
||||||
aria-label="Pick color"
|
aria-label="Pick color"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={currentColorName}
|
value={currentColorName}
|
||||||
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
|
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -408,24 +466,24 @@ export default function NewItemPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={currentInput}
|
value={currentInput}
|
||||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
|
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
addValue(vc.variantCategoryId)
|
addValue(t.itemTypeId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={vc.name}
|
placeholder={t.name}
|
||||||
className="h-11 text-base"
|
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" />
|
<Plus className="size-4" />
|
||||||
Add {vc.name}
|
Add {t.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
|
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
|
||||||
const decoded = isColor ? decodeColorValue(v) : null
|
const decoded = isColor ? decodeColorValue(v) : null
|
||||||
return (
|
return (
|
||||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
<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}
|
{decoded ? decoded.name : v}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeValue(vc.variantCategoryId, v)}
|
onClick={() => removeValue(t.itemTypeId, v)}
|
||||||
className="rounded-full p-0.5 hover:bg-muted"
|
className="rounded-full p-0.5 hover:bg-muted"
|
||||||
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
||||||
>
|
>
|
||||||
@@ -461,10 +519,13 @@ export default function NewItemPage() {
|
|||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow className="hover:bg-indigo-50">
|
||||||
{activeCategories.map((cat) => (
|
{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">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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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 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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -505,6 +556,7 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
<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>
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</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">{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">{item.trackingMode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<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(() => {
|
useEffect(() => {
|
||||||
if (!grn) return
|
if (!grn) return
|
||||||
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [grn?.warehouseId])
|
}, [grn?.warehouseId])
|
||||||
|
|
||||||
function itemFor(itemId: number) {
|
function itemFor(itemId: number) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
|
|||||||
setBins([])
|
setBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
function switchMode(next: Mode) {
|
function switchMode(next: Mode) {
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { grnsApi } from "@/lib/api/grns"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||||
|
|
||||||
type StatusFilter = GrnStatus | "All"
|
type StatusFilter = GrnStatus | "All"
|
||||||
@@ -40,7 +38,6 @@ export default function GrnListPage() {
|
|||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Debounce the search box so typing doesn't refetch on every keystroke.
|
// Debounce the search box so typing doesn't refetch on every keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,19 +68,6 @@ export default function GrnListPage() {
|
|||||||
|
|
||||||
useEffect(load, [page, query, status])
|
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"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,7 +180,6 @@ export default function GrnListPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{grns.map((grn) => {
|
{grns.map((grn) => {
|
||||||
const isDraft = grn.status === "Draft"
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={grn.grnId}>
|
<TableRow key={grn.grnId}>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
@@ -224,47 +207,9 @@ export default function GrnListPage() {
|
|||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isDraft ? (
|
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
|
||||||
<Link
|
a GRN. A receipt is corrected with a reversing document, never
|
||||||
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
|
edited or erased (FR-X-05). */}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
|
|||||||
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
||||||
|
|
||||||
useEffect(() => {
|
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]) => {
|
.then(([r, it, wh]) => {
|
||||||
setRows(r)
|
setRows(r.items)
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
|
|||||||
const key = `${alert.itemId}-${alert.warehouseId}`
|
const key = `${alert.itemId}-${alert.warehouseId}`
|
||||||
setRequesting(key)
|
setRequesting(key)
|
||||||
try {
|
try {
|
||||||
|
// The server returns the full requisition; the suggested qty is on its line.
|
||||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||||
setRequested((prev) => new Set(prev).add(key))
|
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) {
|
} catch (err) {
|
||||||
toast.error("Could not create requisition", errorMessage(err))
|
toast.error("Could not create requisition", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
|
|||||||
setSrcBins([])
|
setSrcBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
|
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
|
||||||
}, [srcWarehouseId])
|
}, [srcWarehouseId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
|
|||||||
setDestBins([])
|
setDestBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
|
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
|
||||||
}, [destWarehouseId])
|
}, [destWarehouseId])
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
|
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 { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
||||||
.then(([wh, it, rc]) => {
|
.then(([wh, it, rc]) => {
|
||||||
const wastageIds = new Set(wastageReasonCodeIds())
|
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
setItems(it.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)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
|
|||||||
setBins([])
|
setBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
|
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 { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -33,8 +33,7 @@ export default function WastagePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||||
.then(([rc, it, wh]) => {
|
.then(([rc, it, wh]) => {
|
||||||
const wastageIds = new Set(wastageReasonCodeIds())
|
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
|
||||||
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
|
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function WarehouseDetailPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
function loadBins() {
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function WarehousesPage() {
|
|||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
setWarehouses(res.items)
|
setWarehouses(res.items)
|
||||||
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
|
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)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { Eye, EyeOff } from "lucide-react"
|
import { Eye, EyeOff } from "lucide-react"
|
||||||
import { loginSchema, LoginValues } from "@/lib/validations"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -39,8 +42,10 @@ function GoogleIcon() {
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [remember, setRemember] = useState(false)
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
const form = useForm<LoginValues>({
|
const form = useForm<LoginValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
@@ -48,8 +53,18 @@ export default function LoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
console.log(values)
|
setSubmitError(null)
|
||||||
router.push("/dashboard")
|
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 (
|
return (
|
||||||
@@ -79,6 +94,11 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
|
<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">
|
<FieldGroup className="space-y-5">
|
||||||
<Field data-invalid={!!form.formState.errors.email}>
|
<Field data-invalid={!!form.formState.errors.email}>
|
||||||
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
|
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Ruler,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
SlidersHorizontal,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tag,
|
Tag,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -43,7 +45,9 @@ const navItems: {
|
|||||||
{ title: "Item", href: "/dashboard/products", icon: Boxes },
|
{ title: "Item", href: "/dashboard/products", icon: Boxes },
|
||||||
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
||||||
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
|
{ 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 },
|
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
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 { cn } from "@/lib/utils"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -17,6 +20,14 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
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> = {
|
const PROCUREMENT_TITLES: Record<string, string> = {
|
||||||
"/dashboard/procurement": "Procurement",
|
"/dashboard/procurement": "Procurement",
|
||||||
"/dashboard/procurement/requisitions": "Requisitions",
|
"/dashboard/procurement/requisitions": "Requisitions",
|
||||||
@@ -69,6 +80,10 @@ function titleFromPath(pathname: string) {
|
|||||||
if (pathname === "/dashboard/products/new") return "New Item"
|
if (pathname === "/dashboard/products/new") return "New Item"
|
||||||
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
|
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
|
||||||
if (pathname === "/dashboard/products/categories") return "Categories"
|
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"
|
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
|
||||||
|
|
||||||
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
|
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
|
||||||
@@ -122,9 +137,31 @@ export function Header() {
|
|||||||
const [notifications, setNotifications] = useState(initialNotifications)
|
const [notifications, setNotifications] = useState(initialNotifications)
|
||||||
const unreadCount = notifications.filter((n) => n.unread).length
|
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 = () =>
|
const markAllAsRead = () =>
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
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 (
|
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">
|
<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">
|
<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">
|
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
|
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
|
||||||
JM
|
{initials(displayName(user))}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
|
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
|
||||||
John Martinez
|
{displayName(user)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||||
<div className="px-2 py-2.5">
|
<div className="px-2 py-2.5">
|
||||||
<p className="text-base font-semibold text-slate-900">John Martinez</p>
|
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
|
||||||
<p className="text-sm font-normal text-muted-foreground">john52martinez@gmail.com</p>
|
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -242,7 +279,7 @@ export function Header() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
render={<Link href="/login" />}
|
onClick={handleLogout}
|
||||||
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
|
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
|
||||||
>
|
>
|
||||||
<LogOut />
|
<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 }
|
||||||
@@ -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}` : ""
|
||||||
|
}
|
||||||
@@ -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: {} })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,64 +1,39 @@
|
|||||||
// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts.
|
// One typed client method per Brand endpoint (docs/11-BACKEND-PHASE1.md §2.6; FR-MD-09).
|
||||||
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { PagedResponse } from "@/types/common"
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
|
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
|
||||||
import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListBrandsParams {
|
export interface ListBrandsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
sortOrder?: "asc" | "desc"
|
status?: EntityStatus
|
||||||
|
/** `name` / `-name` etc. (docs/11 §1.5). */
|
||||||
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const brandsApi = {
|
export const brandsApi = {
|
||||||
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
|
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<Brand>>(`/brands${buildQuery(params)}`)
|
||||||
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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateBrandRequest): Promise<Brand> {
|
get(brandId: number): Promise<ApiResult<Brand>> {
|
||||||
const name = request.name.trim()
|
return apiRequestWithETag<Brand>(`/brands/${brandId}`)
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update(brandId: number, request: UpdateBrandRequest): Promise<Brand> {
|
create(request: CreateBrandRequest): Promise<ApiResult<Brand>> {
|
||||||
const name = request.name.trim()
|
return apiRequestWithETag<Brand>("/brands", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(brandId: number): Promise<void> {
|
update(brandId: number, request: UpdateBrandRequest, ifMatch: string): Promise<ApiResult<Brand>> {
|
||||||
const index = mockBrands.findIndex((b) => b.brandId === brandId)
|
return apiRequestWithETag<Brand>(`/brands/${brandId}`, { method: "PUT", body: request, ifMatch })
|
||||||
if (index === -1) return Promise.reject(new Error("Brand not found."))
|
},
|
||||||
mockBrands.splice(index, 1)
|
|
||||||
return mockDelay(undefined)
|
/**
|
||||||
|
* 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 } })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,80 @@
|
|||||||
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
|
// One typed client method per Category / SubCategory endpoint
|
||||||
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
|
// (docs/11-BACKEND-PHASE1.md §2.3; FR-MD-04).
|
||||||
import { PagedResponse } from "@/types/common"
|
//
|
||||||
import { Category, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/master-data"
|
// The hierarchy is exactly two levels. Categories no longer self-nest — `parentId` and
|
||||||
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
|
// `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 {
|
export interface ListCategoriesParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
sortOrder?: "asc" | "desc"
|
status?: EntityStatus
|
||||||
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListSubCategoriesParams = ListCategoriesParams
|
||||||
|
|
||||||
export const categoriesApi = {
|
export const categoriesApi = {
|
||||||
list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
|
list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<Category>>(`/categories${buildQuery(params)}`)
|
||||||
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)))
|
|
||||||
|
|
||||||
const page = params.page ?? 1
|
get(categoryId: number): Promise<ApiResult<Category>> {
|
||||||
const pageSize = params.pageSize ?? 5
|
return apiRequestWithETag<Category>(`/categories/${categoryId}`)
|
||||||
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({
|
create(request: CreateCategoryRequest): Promise<ApiResult<Category>> {
|
||||||
items,
|
return apiRequestWithETag<Category>("/categories", { method: "POST", body: request })
|
||||||
pagination: { page, pageSize, totalItems, totalPages },
|
},
|
||||||
|
|
||||||
|
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> {
|
updateStatus(subCategoryId: number, status: EntityStatus): Promise<void> {
|
||||||
const name = request.name.trim()
|
return apiRequest<void>(`/subcategories/${subCategoryId}/status`, { method: "PATCH", body: { status } })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,223 +1,61 @@
|
|||||||
// One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4).
|
// 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
|
// `confirm` is the transactional one: the SERVER creates the FIFO layers, posts the
|
||||||
// the note in Frontend/PROGRESS.md §4.
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
ConfirmGrnResponse,
|
ConfirmGrnResponse,
|
||||||
CreateGrnRequest,
|
CreateGrnRequest,
|
||||||
CreatedLayer,
|
|
||||||
Grn,
|
Grn,
|
||||||
GrnStatus,
|
GrnStatus,
|
||||||
GrnSummary,
|
GrnSummary,
|
||||||
ReleaseAction,
|
ReleaseAction,
|
||||||
ReleaseGrnLineResponse,
|
ReleaseGrnLineResponse,
|
||||||
} from "@/types/grn"
|
} from "@/types/grn"
|
||||||
import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListGrnsParams {
|
export interface ListGrnsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
/** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */
|
|
||||||
q?: string
|
q?: string
|
||||||
status?: GrnStatus
|
status?: GrnStatus
|
||||||
poId?: number
|
poId?: number
|
||||||
|
vendorId?: number
|
||||||
warehouseId?: number
|
warehouseId?: number
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const grnsApi = {
|
export const grnsApi = {
|
||||||
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
|
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
|
||||||
|
|
||||||
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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(grnId: number): Promise<Grn> {
|
get(grnId: number): Promise<Grn> {
|
||||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
return apiRequest<Grn>(`/grns/${grnId}`)
|
||||||
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
|
|
||||||
return mockDelay(grn)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 422 OVER_RECEIPT_TOLERANCE if qty exceeds the PO's open qty beyond tolerance. */
|
||||||
create(request: CreateGrnRequest): Promise<Grn> {
|
create(request: CreateGrnRequest): Promise<Grn> {
|
||||||
const grnId = allocateGrnId()
|
return apiRequest<Grn>("/grns", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
confirm(grnId: number, idempotencyKey?: string): Promise<ConfirmGrnResponse> {
|
||||||
void idempotencyKey // real backend dedupes on this; the mock always reprocesses
|
return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, { method: "POST", idempotencyKey })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** "Release" makes an on-hold line issuable; "Reject" routes it to a purchase return. */
|
||||||
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
|
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
|
||||||
const grn = mockGrns.find((g) => g.grnId === grnId)
|
return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
|
||||||
const line = grn?.lines.find((l) => l.grnLineId === grnLineId)
|
method: "POST",
|
||||||
if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`))
|
body: { action },
|
||||||
|
})
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
// One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08).
|
// 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.
|
// `list` also backs the GRN/PO/Requisition/RFQ item pickers.
|
||||||
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
CreateItemRequest,
|
CreateItemRequest,
|
||||||
Item,
|
Item,
|
||||||
ItemListItem,
|
ItemListItem,
|
||||||
|
ItemReorderSetting,
|
||||||
TrackingMode,
|
TrackingMode,
|
||||||
UpdateItemReorderRequest,
|
UpdateItemReorderRequest,
|
||||||
UpdateItemRequest,
|
UpdateItemRequest,
|
||||||
UpdateUomConversionsRequest,
|
UpdateUomConversionsRequest,
|
||||||
UpdateUomConversionsResponse,
|
UpdateUomConversionsResponse,
|
||||||
} from "@/types/master-data"
|
} from "@/types/master-data"
|
||||||
import {
|
|
||||||
allocateItemId,
|
|
||||||
bumpItemVersion,
|
|
||||||
getItemVersion,
|
|
||||||
initItemVersion,
|
|
||||||
mockDelay,
|
|
||||||
mockItems,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListItemsParams {
|
export interface ListItemsParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -27,139 +20,50 @@ export interface ListItemsParams {
|
|||||||
q?: string
|
q?: string
|
||||||
status?: EntityStatus
|
status?: EntityStatus
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
|
subCategoryId?: number
|
||||||
|
brandId?: number
|
||||||
trackingMode?: TrackingMode
|
trackingMode?: TrackingMode
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemsApi = {
|
export const itemsApi = {
|
||||||
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
|
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
||||||
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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(itemId: number): Promise<ApiResult<Item>> {
|
get(itemId: number): Promise<ApiResult<Item>> {
|
||||||
const item = mockItems.find((i) => i.itemId === itemId)
|
return apiRequestWithETag<Item>(`/items/${itemId}`)
|
||||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
|
||||||
return mockDelay({ data: item, etag: String(getItemVersion(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>> {
|
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
|
||||||
const sku = request.sku.trim()
|
return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
|
||||||
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" })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
|
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
|
||||||
const item = mockItems.find((i) => i.itemId === itemId)
|
return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
|
||||||
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) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
|
||||||
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
|
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
|
||||||
const item = mockItems.find((i) => i.itemId === itemId)
|
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
|
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
|
||||||
const item = mockItems.find((i) => i.itemId === itemId)
|
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
|
||||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
method: "PUT",
|
||||||
item.reorder = request.settings
|
body: request,
|
||||||
item.updatedAt = new Date().toISOString()
|
})
|
||||||
bumpItemVersion(itemId)
|
|
||||||
return mockDelay({ settings: item.reorder })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||||
const item = mockItems.find((i) => i.itemId === itemId)
|
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
method: "PUT",
|
||||||
item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor }))
|
body: request,
|
||||||
item.updatedAt = new Date().toISOString()
|
})
|
||||||
bumpItemVersion(itemId)
|
|
||||||
return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3,
|
// 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.
|
// 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 { ApiResult, PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
CancelPurchaseOrderRequest,
|
CancelPurchaseOrderRequest,
|
||||||
@@ -10,14 +10,6 @@ import {
|
|||||||
PurchaseOrderSummary,
|
PurchaseOrderSummary,
|
||||||
UpdatePurchaseOrderRequest,
|
UpdatePurchaseOrderRequest,
|
||||||
} from "@/types/procurement"
|
} from "@/types/procurement"
|
||||||
import {
|
|
||||||
allocatePoId,
|
|
||||||
bumpPoVersion,
|
|
||||||
getPoVersion,
|
|
||||||
initPoVersion,
|
|
||||||
mockDelay,
|
|
||||||
mockPurchaseOrders,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListPurchaseOrdersParams {
|
export interface ListPurchaseOrdersParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -25,138 +17,45 @@ export interface ListPurchaseOrdersParams {
|
|||||||
q?: string
|
q?: string
|
||||||
status?: PurchaseOrderStatus
|
status?: PurchaseOrderStatus
|
||||||
vendorId?: number
|
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 {
|
export function isPoEditable(status: PurchaseOrderStatus): boolean {
|
||||||
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
|
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 = {
|
export const purchaseOrdersApi = {
|
||||||
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
|
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
|
||||||
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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(poId: number): Promise<PurchaseOrder> {
|
get(poId: number): Promise<PurchaseOrder> {
|
||||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
|
||||||
return mockDelay(po)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
|
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
|
||||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
|
||||||
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Auto-approved on creation in Phase 1 (approvalRequired defaults false, FR-PROC-04). */
|
||||||
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
|
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
|
||||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
|
||||||
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" })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 409 PO_NOT_EDITABLE once fully received/closed/cancelled. */
|
||||||
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
|
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
|
||||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
|
||||||
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) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 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> {
|
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,32 @@
|
|||||||
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08).
|
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4,
|
||||||
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
|
// FR-PROC-08). Auto-posts an outbound FIFO movement on create.
|
||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
import { PagedResponse } from "@/types/common"
|
import { PagedResponse } from "@/types/common"
|
||||||
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
|
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
|
||||||
import {
|
|
||||||
allocatePurchaseReturnId,
|
|
||||||
allocatePurchaseReturnLineId,
|
|
||||||
consumeLayerByGrnLine,
|
|
||||||
mockDelay,
|
|
||||||
mockPurchaseReturns,
|
|
||||||
postLedgerEntry,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
function toSummary(r: PurchaseReturn): PurchaseReturnSummary {
|
export interface ListPurchaseReturnsParams {
|
||||||
return {
|
page?: number
|
||||||
returnId: r.returnId,
|
pageSize?: number
|
||||||
docNo: r.docNo,
|
q?: string
|
||||||
vendorId: r.vendorId,
|
vendorId?: number
|
||||||
warehouseId: r.warehouseId,
|
warehouseId?: number
|
||||||
reasonCodeId: r.reasonCodeId,
|
sort?: string
|
||||||
status: r.status,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const purchaseReturnsApi = {
|
export const purchaseReturnsApi = {
|
||||||
list(): Promise<PagedResponse<PurchaseReturnSummary>> {
|
list(params: ListPurchaseReturnsParams = {}): Promise<PagedResponse<PurchaseReturnSummary>> {
|
||||||
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
|
return apiRequest<PagedResponse<PurchaseReturnSummary>>(`/purchase-returns${buildQuery(params)}`)
|
||||||
return mockDelay({
|
|
||||||
items,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(returnId: number): Promise<PurchaseReturn> {
|
get(returnId: number): Promise<PurchaseReturn> {
|
||||||
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
|
return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
|
||||||
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
|
|
||||||
return mockDelay(r)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
|
||||||
if (!request.reasonCodeId) {
|
return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { ReasonCode, ReasonCodeContext } from "@/types/stock"
|
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 = {
|
export const reasonCodesApi = {
|
||||||
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
|
list(context?: ReasonCodeContext, params: Omit<ListReasonCodesParams, "context"> = {}): Promise<PagedResponse<ReasonCode>> {
|
||||||
const items = mockReasonCodes.filter((r) => !context || r.context === context)
|
return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context, ...params })}`)
|
||||||
return mockDelay({
|
|
||||||
items,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,31 @@
|
|||||||
// One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
||||||
import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListRequisitionsParams {
|
export interface ListRequisitionsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
status?: RequisitionStatus
|
status?: RequisitionStatus
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const requisitionsApi = {
|
export const requisitionsApi = {
|
||||||
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
|
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
|
||||||
const filtered = mockRequisitions
|
return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
|
||||||
.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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(requisitionId: number): Promise<Requisition> {
|
get(requisitionId: number): Promise<Requisition> {
|
||||||
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
|
return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
|
||||||
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
|
|
||||||
return mockDelay(r)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** `requestedBy` is stamped from the session, never posted. */
|
||||||
create(request: CreateRequisitionRequest): Promise<Requisition> {
|
create(request: CreateRequisitionRequest): Promise<Requisition> {
|
||||||
if (request.lines.length === 0) {
|
return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
submit(requisitionId: number): Promise<Requisition> {
|
submit(requisitionId: number): Promise<Requisition> {
|
||||||
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
|
return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST" })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
CreateQuotationRequest,
|
CreateQuotationRequest,
|
||||||
@@ -7,94 +10,38 @@ import {
|
|||||||
Quotation,
|
Quotation,
|
||||||
Rfq,
|
Rfq,
|
||||||
RfqComparison,
|
RfqComparison,
|
||||||
RfqComparisonLine,
|
RfqStatus,
|
||||||
RfqSummary,
|
RfqSummary,
|
||||||
} from "@/types/procurement"
|
} from "@/types/procurement"
|
||||||
import {
|
|
||||||
allocateQuotationId,
|
|
||||||
allocateRfqId,
|
|
||||||
allocateRfqLineId,
|
|
||||||
mockDelay,
|
|
||||||
mockQuotations,
|
|
||||||
mockRfqs,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
function toSummary(r: Rfq): RfqSummary {
|
export interface ListRfqsParams {
|
||||||
return {
|
page?: number
|
||||||
rfqId: r.rfqId,
|
pageSize?: number
|
||||||
docNo: r.docNo,
|
q?: string
|
||||||
requisitionId: r.requisitionId,
|
status?: RfqStatus
|
||||||
status: r.status,
|
sort?: string
|
||||||
vendorIds: r.vendorIds,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rfqsApi = {
|
export const rfqsApi = {
|
||||||
list(): Promise<PagedResponse<RfqSummary>> {
|
list(params: ListRfqsParams = {}): Promise<PagedResponse<RfqSummary>> {
|
||||||
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
|
return apiRequest<PagedResponse<RfqSummary>>(`/rfqs${buildQuery(params)}`)
|
||||||
return mockDelay({
|
|
||||||
items,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(rfqId: number): Promise<Rfq> {
|
get(rfqId: number): Promise<Rfq> {
|
||||||
const r = mockRfqs.find((x) => x.rfqId === rfqId)
|
return apiRequest<Rfq>(`/rfqs/${rfqId}`)
|
||||||
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
|
||||||
return mockDelay(r)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateRfqRequest): Promise<Rfq> {
|
create(request: CreateRfqRequest): Promise<Rfq> {
|
||||||
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
|
return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** One quotation per vendor per RFQ; a duplicate returns 409. */
|
||||||
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
|
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
|
||||||
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
|
return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Server-computed vendor-by-line price matrix. */
|
||||||
comparison(rfqId: number): Promise<RfqComparison> {
|
comparison(rfqId: number): Promise<RfqComparison> {
|
||||||
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
|
return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,32 @@
|
|||||||
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
||||||
import {
|
|
||||||
allocateAdjLineId,
|
|
||||||
allocateAdjustmentId,
|
|
||||||
consumeFifo,
|
|
||||||
lastKnownCost,
|
|
||||||
mockDelay,
|
|
||||||
mockStockAdjustments,
|
|
||||||
postLedgerEntry,
|
|
||||||
receiveLayer,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListAdjustmentsParams {
|
export interface ListAdjustmentsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
warehouseId?: number
|
warehouseId?: number
|
||||||
}
|
reasonCodeId?: number
|
||||||
|
sort?: string
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stockAdjustmentsApi = {
|
export const stockAdjustmentsApi = {
|
||||||
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
|
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
|
||||||
const filtered = mockStockAdjustments
|
return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
|
||||||
.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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(adjustmentId: number): Promise<StockAdjustment> {
|
get(adjustmentId: number): Promise<StockAdjustment> {
|
||||||
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
|
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
|
||||||
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
|
|
||||||
return mockDelay(a)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
|
||||||
if (!request.reasonCodeId) {
|
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +1,45 @@
|
|||||||
// One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
CountStatus,
|
CountStatus,
|
||||||
CreateCountRequest,
|
CreateCountRequest,
|
||||||
EnterCountsRequest,
|
EnterCountsRequest,
|
||||||
EnterCountsResponse,
|
|
||||||
PostCountResponse,
|
PostCountResponse,
|
||||||
StockCount,
|
StockCount,
|
||||||
StockCountSummary,
|
StockCountSummary,
|
||||||
} from "@/types/stock"
|
} from "@/types/stock"
|
||||||
import {
|
|
||||||
allocateAdjLineId,
|
|
||||||
allocateAdjustmentId,
|
|
||||||
allocateCountId,
|
|
||||||
allocateCountLineId,
|
|
||||||
computeOnHand,
|
|
||||||
consumeFifo,
|
|
||||||
lastKnownCost,
|
|
||||||
mockDelay,
|
|
||||||
mockStockAdjustments,
|
|
||||||
mockStockCounts,
|
|
||||||
postLedgerEntry,
|
|
||||||
receiveLayer,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListCountsParams {
|
export interface ListCountsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
status?: CountStatus
|
||||||
warehouseId?: number
|
warehouseId?: number
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stockCountsApi = {
|
export const stockCountsApi = {
|
||||||
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
|
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
|
||||||
const filtered = mockStockCounts
|
return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
|
||||||
.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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(countId: number): Promise<StockCount> {
|
get(countId: number): Promise<StockCount> {
|
||||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
return apiRequest<StockCount>(`/stock-counts/${countId}`)
|
||||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
|
||||||
return mockDelay(c)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateCountRequest): Promise<StockCount> {
|
create(request: CreateCountRequest): Promise<StockCount> {
|
||||||
const countId = allocateCountId()
|
return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
|
/** Returns the whole count (with server-computed variance), not just the lines. */
|
||||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
enterCounts(countId: number, request: EnterCountsRequest): Promise<StockCount> {
|
||||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
return apiRequest<StockCount>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
|
||||||
|
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Posts the variance adjustment and closes the count. `adjustmentId` is null if there was no variance. */
|
||||||
post(countId: number): Promise<PostCountResponse> {
|
post(countId: number): Promise<PostCountResponse> {
|
||||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST" })
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
CreateTransferRequest,
|
CreateTransferRequest,
|
||||||
@@ -10,173 +12,39 @@ import {
|
|||||||
StockTransferSummary,
|
StockTransferSummary,
|
||||||
TransferStatus,
|
TransferStatus,
|
||||||
} from "@/types/stock"
|
} from "@/types/stock"
|
||||||
import {
|
|
||||||
MockTransferLine,
|
|
||||||
allocateTransferId,
|
|
||||||
allocateTransferLineId,
|
|
||||||
consumeFifo,
|
|
||||||
mockDelay,
|
|
||||||
mockStockTransfers,
|
|
||||||
postLedgerEntry,
|
|
||||||
receiveLayer,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListTransfersParams {
|
export interface ListTransfersParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
status?: TransferStatus
|
status?: TransferStatus
|
||||||
srcWarehouseId?: number
|
srcWarehouseId?: number
|
||||||
destWarehouseId?: number
|
destWarehouseId?: number
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stockTransfersApi = {
|
export const stockTransfersApi = {
|
||||||
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
|
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
|
||||||
const filtered = mockStockTransfers
|
return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
|
||||||
.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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(transferId: number): Promise<StockTransfer> {
|
get(transferId: number): Promise<StockTransfer> {
|
||||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
|
||||||
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
|
|
||||||
return mockDelay(toPublic(t))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateTransferRequest): Promise<StockTransfer> {
|
create(request: CreateTransferRequest): Promise<StockTransfer> {
|
||||||
const transferId = allocateTransferId()
|
return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
|
||||||
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))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 409 STOCK_NEGATIVE_BLOCKED if source available < requested. */
|
||||||
dispatch(transferId: number): Promise<DispatchTransferResponse> {
|
dispatch(transferId: number): Promise<DispatchTransferResponse> {
|
||||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST" })
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
|
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
|
||||||
const t = mockStockTransfers.find((x) => x.transferId === transferId)
|
return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
|
||||||
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
|
method: "POST",
|
||||||
if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`))
|
body: { lines },
|
||||||
|
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,60 @@
|
|||||||
// One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7).
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock"
|
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 {
|
export interface LedgerQuery {
|
||||||
itemId?: number
|
itemId?: number
|
||||||
warehouseId?: number
|
warehouseId?: number
|
||||||
|
/** `YYYY-MM-DD`. */
|
||||||
from?: string
|
from?: string
|
||||||
to?: 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
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stockApi = {
|
export const stockApi = {
|
||||||
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
|
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
|
||||||
const computed = computeOnHand(itemId, warehouseId)
|
return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
|
||||||
return mockDelay({
|
|
||||||
itemId,
|
|
||||||
warehouseId,
|
|
||||||
...computed,
|
|
||||||
asOf: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
|
/** Every (item, warehouse) pair holding stock. Paged, unlike the old client-side version. */
|
||||||
onHandList(): Promise<OnHand[]> {
|
onHandList(params: OnHandListParams = {}): Promise<PagedResponse<OnHand>> {
|
||||||
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
|
return apiRequest<PagedResponse<OnHand>>(`/stock/on-hand/list${buildQuery(params)}`)
|
||||||
itemId,
|
|
||||||
warehouseId,
|
|
||||||
...computeOnHand(itemId, warehouseId),
|
|
||||||
asOf: new Date().toISOString(),
|
|
||||||
}))
|
|
||||||
return mockDelay(rows)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
|
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
|
||||||
const from = params.from ? new Date(params.from).getTime() : null
|
return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
|
||||||
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),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
|
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
|
||||||
const layers = mockStockLayers
|
return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
|
||||||
.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",
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
|
/** Items at or below their reorder point — computed on read, no stored entity (FR-STK-10). */
|
||||||
const items = mockItemReorders
|
reorderAlerts(warehouseId?: number, params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<ReorderAlert>> {
|
||||||
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
|
return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId, ...params })}`)
|
||||||
.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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Creates a draft requisition for the suggested qty; returns the full requisition. */
|
||||||
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
|
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
|
||||||
const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId)
|
return apiRequest<ReorderRequisitionResponse>(
|
||||||
const qty = setting?.reorderQty ?? 0
|
`/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
|
||||||
const requisitionId = allocateRequisitionId()
|
{ method: "POST" },
|
||||||
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",
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
|
// 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.
|
// `list` also backs the GRN/PO line UOM picker.
|
||||||
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { CreateUomRequest, Uom } from "@/types/master-data"
|
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 = {
|
export const uomsApi = {
|
||||||
list(): Promise<PagedResponse<Uom>> {
|
list(params: ListUomsParams = {}): Promise<PagedResponse<Uom>> {
|
||||||
return mockDelay({
|
return apiRequest<PagedResponse<Uom>>(`/uoms${buildQuery(params)}`)
|
||||||
items: mockUoms,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateUomRequest): Promise<Uom> {
|
create(request: CreateUomRequest): Promise<Uom> {
|
||||||
const name = request.name.trim()
|
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,120 +1,36 @@
|
|||||||
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
|
// 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
|
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are
|
||||||
// are deactivated, not hard-deleted, FR-MD-08). In-memory sample data
|
// deactivated, not hard-deleted, FR-MD-08).
|
||||||
// (lib/api/mock-data.ts) — no backend API calls.
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import { Vendor } from "@/types/master-data"
|
import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data"
|
||||||
import {
|
|
||||||
allocateVendorId,
|
|
||||||
bumpVendorVersion,
|
|
||||||
getVendorVersion,
|
|
||||||
initVendorVersion,
|
|
||||||
mockDelay,
|
|
||||||
mockVendors,
|
|
||||||
} from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface ListVendorsParams {
|
export interface ListVendorsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
status?: EntityStatus
|
status?: EntityStatus
|
||||||
}
|
sort?: string
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vendorsApi = {
|
export const vendorsApi = {
|
||||||
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
|
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
|
||||||
const term = params.q?.trim().toLowerCase()
|
return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
|
||||||
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 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(vendorId: number): Promise<ApiResult<Vendor>> {
|
get(vendorId: number): Promise<ApiResult<Vendor>> {
|
||||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
|
||||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
|
||||||
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
|
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
|
||||||
const code = request.code.trim()
|
return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
|
||||||
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" })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
|
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
|
||||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
|
||||||
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) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
||||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,39 @@
|
|||||||
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
|
// 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 { PagedResponse } from "@/types/common"
|
||||||
import { Bin, Warehouse } from "@/types/master-data"
|
import { Bin, CreateBinRequest, CreateWarehouseRequest, Warehouse } from "@/types/master-data"
|
||||||
import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data"
|
|
||||||
|
|
||||||
export interface CreateWarehouseRequest {
|
export type { CreateBinRequest, CreateWarehouseRequest }
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateBinRequest {
|
export interface ListWarehousesParams {
|
||||||
code: string
|
page?: number
|
||||||
binType?: string | null
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const warehousesApi = {
|
export const warehousesApi = {
|
||||||
list(): Promise<PagedResponse<Warehouse>> {
|
list(params: ListWarehousesParams = {}): Promise<PagedResponse<Warehouse>> {
|
||||||
return mockDelay({
|
return apiRequest<PagedResponse<Warehouse>>(`/warehouses${buildQuery(params)}`)
|
||||||
items: mockWarehouses,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get(warehouseId: number): Promise<Warehouse> {
|
get(warehouseId: number): Promise<Warehouse> {
|
||||||
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
|
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`)
|
||||||
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
|
|
||||||
return mockDelay(wh)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateWarehouseRequest): Promise<Warehouse> {
|
create(request: CreateWarehouseRequest): Promise<Warehouse> {
|
||||||
const code = request.code.trim()
|
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
|
/** Returns a bare array, not a paged envelope — the server does not page bins. */
|
||||||
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
|
listBins(warehouseId: number): Promise<Bin[]> {
|
||||||
return mockDelay({
|
return apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
|
||||||
items,
|
|
||||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
|
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
|
||||||
const code = request.code.trim()
|
return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the
|
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the SRS —
|
||||||
// SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock
|
// stock write-offs (damage, theft/loss, expiry) are modelled as Stock Adjustments with a
|
||||||
// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed
|
// mandatory reason code (FR-STK-07), and the seeded reason codes (docs/10 §B.8.3) already
|
||||||
// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry
|
// include Damage / Theft-Loss / Expiry Write-off. This module is a frontend-only lens over
|
||||||
// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi
|
// stockAdjustmentsApi: filtered to loss-type reasons and flattened to per-line records for
|
||||||
// and the shared mock Stock Core, filtered to loss-type reason codes and
|
// a focused "record wastage" flow and report. No new backend concept.
|
||||||
// flattened to per-line records for a focused "record wastage" flow and report.
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
// No new backend concept, no new mock store.
|
|
||||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
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"
|
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[] {
|
/** Sync predicate for screens that already hold the reason-code list. */
|
||||||
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
|
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 {
|
export interface WastageRecord {
|
||||||
@@ -28,13 +37,15 @@ export interface WastageRecord {
|
|||||||
reasonCodeId: number
|
reasonCodeId: number
|
||||||
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
|
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
|
||||||
qty: number
|
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
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListWastageParams {
|
export interface ListWastageParams {
|
||||||
warehouseId?: number
|
warehouseId?: number
|
||||||
reasonCodeId?: number
|
reasonCodeId?: number
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordWastageInput {
|
export interface RecordWastageInput {
|
||||||
@@ -46,45 +57,58 @@ export interface RecordWastageInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const wastageApi = {
|
export const wastageApi = {
|
||||||
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
|
/**
|
||||||
const wastageIds = new Set(wastageReasonCodeIds())
|
* Flattens loss-type adjustments to per-item records.
|
||||||
const records: WastageRecord[] = []
|
*
|
||||||
|
* 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) {
|
// The API filters by a single reasonCodeId, so a specific pick can be pushed down;
|
||||||
if (!wastageIds.has(adj.reasonCodeId)) continue
|
// otherwise fetch the page and keep the loss-type rows.
|
||||||
if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue
|
const res = await stockAdjustmentsApi.list({
|
||||||
if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue
|
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) {
|
const detailed = await Promise.all(
|
||||||
if (line.qtyDelta >= 0) continue // wastage is always a decrease
|
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
|
return adjustment.lines
|
||||||
.filter(
|
.filter((line) => line.qtyDelta < 0) // wastage is always a decrease
|
||||||
(l) =>
|
.map<WastageRecord>((line) => {
|
||||||
l.sourceDocType === "Adjustment" &&
|
const value = ledger.items
|
||||||
l.sourceDocId === adj.adjustmentId &&
|
.filter((l) => l.itemId === line.itemId && l.direction === "Out")
|
||||||
l.itemId === line.itemId &&
|
|
||||||
l.direction === "Out"
|
|
||||||
)
|
|
||||||
.reduce((sum, l) => sum + l.value, 0)
|
.reduce((sum, l) => sum + l.value, 0)
|
||||||
|
return {
|
||||||
records.push({
|
adjustmentId: adjustment.adjustmentId,
|
||||||
adjustmentId: adj.adjustmentId,
|
|
||||||
adjLineId: line.adjLineId,
|
adjLineId: line.adjLineId,
|
||||||
docNo: adj.docNo,
|
docNo: adjustment.docNo,
|
||||||
createdAt: adj.createdAt,
|
createdAt: adjustment.createdAt,
|
||||||
warehouseId: adj.warehouseId,
|
warehouseId: adjustment.warehouseId,
|
||||||
itemId: line.itemId,
|
itemId: line.itemId,
|
||||||
binId: line.binId,
|
binId: line.binId,
|
||||||
reasonCodeId: adj.reasonCodeId,
|
reasonCodeId: adjustment.reasonCodeId,
|
||||||
qty: Math.abs(line.qtyDelta),
|
qty: Math.abs(line.qtyDelta),
|
||||||
value: Math.round(value * 100) / 100,
|
value: Math.round(value * 100) / 100,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}),
|
||||||
}
|
)
|
||||||
|
|
||||||
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
|
return detailed.flat().sort((a, b) => b.adjustmentId - a.adjustmentId)
|
||||||
return mockDelay(records)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
|
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ interface ApiErrorLike {
|
|||||||
errors?: Record<string, string[]>
|
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> = {
|
const CODE_MESSAGES: Record<string, string> = {
|
||||||
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
|
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.",
|
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 {
|
export function errorMessage(error: unknown): string {
|
||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
const e = error as ApiErrorLike
|
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.detail) return e.detail
|
||||||
|
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
|
||||||
}
|
}
|
||||||
if (error instanceof Error) return error.message
|
if (error instanceof Error) return error.message
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
// Treats null/undefined as missing so validation surfaces one clear
|
// Plain z.string(), not z.preprocess(): preprocess widens the schema's INPUT type to
|
||||||
// "required" message instead of a generic type error.
|
// `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) {
|
function requiredString(message: string) {
|
||||||
return z.preprocess(
|
return z.string().min(1, message)
|
||||||
(val) => (val === null || val === undefined ? "" : val),
|
|
||||||
z.string().min(1, message)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email validation schema
|
// Email validation schema
|
||||||
export const emailSchema = z.preprocess(
|
export const emailSchema = z.string().min(1, "Email is required").email("Enter a valid email")
|
||||||
(val) => (val === null || val === undefined ? "" : val),
|
|
||||||
z.string().min(1, "Email is required").email("Enter a valid email")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Login schema (email + password) for reuse across the app
|
// Login schema (email + password) for reuse across the app
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ export function validateBrandName(name: string): Record<string, string> {
|
|||||||
return errors
|
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> = {}
|
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
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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*"],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
|
// 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):
|
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for
|
||||||
// the doc only specifies POST /grns, POST /grns/{id}/confirm and
|
// Serial-tracked items, but the backend's CreateGrnLineInput has no such field (only
|
||||||
// POST /grns/{id}/lines/{id}/release. A usable list/detail screen needs
|
// `batch`). There is therefore deliberately no `serialNumbers` here: typing it would
|
||||||
// GET /grns and GET /grns/{id} — these are modelled here as a planned,
|
// invite the UI to send data the server silently drops. Tracked in Frontend/PROGRESS.md.
|
||||||
// not-yet-built backend extension (see Backend/PROGRESS.md when it lands).
|
|
||||||
|
|
||||||
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
|
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
|
||||||
export type HoldStatus = "OnHold" | "Available" | "Rejected"
|
export type HoldStatus = "OnHold" | "Available" | "Rejected"
|
||||||
|
export type PurchaseOrderStatus =
|
||||||
|
| "Draft"
|
||||||
|
| "PendingApproval"
|
||||||
|
| "Approved"
|
||||||
|
| "PartiallyReceived"
|
||||||
|
| "FullyReceived"
|
||||||
|
| "Closed"
|
||||||
|
| "Cancelled"
|
||||||
|
|
||||||
export interface BatchInput {
|
export interface BatchInput {
|
||||||
batchNo: string
|
batchNo: string
|
||||||
@@ -24,8 +32,6 @@ export interface CreateGrnLineInput {
|
|||||||
unitCost: number
|
unitCost: number
|
||||||
holdStatus: HoldStatus
|
holdStatus: HoldStatus
|
||||||
batch?: BatchInput | null
|
batch?: BatchInput | null
|
||||||
/** FR-GRN-04 — required when the item's trackingMode is "Serial"; length must equal qty. */
|
|
||||||
serialNumbers?: string[] | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGrnRequest {
|
export interface CreateGrnRequest {
|
||||||
@@ -58,6 +64,7 @@ export interface Grn {
|
|||||||
status: GrnStatus
|
status: GrnStatus
|
||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
postedAt: string | null
|
||||||
lines: GrnLine[]
|
lines: GrnLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +75,10 @@ export interface GrnSummary {
|
|||||||
vendorId: number
|
vendorId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
status: GrnStatus
|
status: GrnStatus
|
||||||
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
postedAt: string | null
|
||||||
|
lineCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** docs/11 §4.2 confirm response. */
|
/** docs/11 §4.2 confirm response. */
|
||||||
@@ -89,7 +99,7 @@ export interface ConfirmGrnResponse {
|
|||||||
postedAt: string
|
postedAt: string
|
||||||
createdLayers: CreatedLayer[]
|
createdLayers: CreatedLayer[]
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
poStatus: string | null
|
poStatus: PurchaseOrderStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReleaseAction = "Release" | "Reject"
|
export type ReleaseAction = "Release" | "Reject"
|
||||||
|
|||||||
@@ -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"
|
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 type TrackingMode = "None" | "Batch" | "Serial"
|
||||||
|
|
||||||
export interface ItemListItem {
|
export interface ItemListItem {
|
||||||
@@ -9,10 +15,11 @@ export interface ItemListItem {
|
|||||||
sku: string
|
sku: string
|
||||||
name: string
|
name: string
|
||||||
categoryId: number
|
categoryId: number
|
||||||
brandId?: number | null
|
subCategoryId: number | null
|
||||||
|
brandId: number | null
|
||||||
baseUomId: number
|
baseUomId: number
|
||||||
defaultVendorId: number | null
|
defaultVendorId: number | null
|
||||||
itemType: ItemType
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
trackingMode: TrackingMode
|
||||||
taxClass: string | null
|
taxClass: string | null
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
@@ -32,14 +39,11 @@ export interface UomConversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). `conversions` is a
|
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
|
||||||
* deviation: the documented example response only shows `reorder` inline, and
|
*
|
||||||
* conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions`
|
* Note there is no item-type field: the values chosen in the builder are encoded into the
|
||||||
* (which returns them but has no matching GET). Embedding them here — like the
|
* client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the
|
||||||
* assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app — lets the Item
|
* only record of which colour/size an item is.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export interface Item {
|
export interface Item {
|
||||||
itemId: number
|
itemId: number
|
||||||
@@ -47,33 +51,36 @@ export interface Item {
|
|||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
categoryId: number
|
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
|
baseUomId: number
|
||||||
defaultVendorId: number | null
|
defaultVendorId: number | null
|
||||||
itemType: ItemType
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
trackingMode: TrackingMode
|
||||||
taxClass: string | null
|
taxClass: string | null
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
reorder: ItemReorderSetting[]
|
reorder: ItemReorderSetting[]
|
||||||
conversions: UomConversion[]
|
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
|
createdAt: string
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateItemRequest {
|
export interface CreateItemRequest {
|
||||||
|
/** Client-generated; the server only enforces uniqueness (`SKU_DUPLICATE`). */
|
||||||
sku: string
|
sku: string
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
categoryId: number
|
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
|
brandId?: number | null
|
||||||
baseUomId: number
|
baseUomId: number
|
||||||
defaultVendorId?: number | null
|
defaultVendorId?: number | null
|
||||||
itemType: ItemType
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
trackingMode: TrackingMode
|
||||||
taxClass?: string | null
|
taxClass?: string | null
|
||||||
initialQty?: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateItemRequest = CreateItemRequest
|
export type UpdateItemRequest = CreateItemRequest
|
||||||
@@ -98,6 +105,11 @@ export interface Warehouse {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateWarehouseRequest {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Bin {
|
export interface Bin {
|
||||||
binId: number
|
binId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
@@ -105,6 +117,11 @@ export interface Bin {
|
|||||||
binType: string | null
|
binType: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateBinRequest {
|
||||||
|
code: string
|
||||||
|
binType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Vendor {
|
export interface Vendor {
|
||||||
vendorId: number
|
vendorId: number
|
||||||
code: string
|
code: string
|
||||||
@@ -117,6 +134,16 @@ export interface Vendor {
|
|||||||
updatedAt: string | null
|
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 {
|
export interface Uom {
|
||||||
uomId: number
|
uomId: number
|
||||||
name: string
|
name: string
|
||||||
@@ -126,30 +153,49 @@ export interface CreateUomRequest {
|
|||||||
name: string
|
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 {
|
export interface Category {
|
||||||
categoryId: number
|
categoryId: number
|
||||||
name: string
|
name: string
|
||||||
parentId: number | null
|
status: EntityStatus
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
updatedAt: string | null
|
||||||
|
|
||||||
export interface CategoryTreeNode extends Category {
|
|
||||||
children: CategoryTreeNode[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCategoryRequest {
|
export interface CreateCategoryRequest {
|
||||||
name: string
|
name: string
|
||||||
parentId?: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCategoryRequest {
|
export interface UpdateCategoryRequest {
|
||||||
name: string
|
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 {
|
export interface Brand {
|
||||||
brandId: number
|
brandId: number
|
||||||
name: string
|
name: string
|
||||||
|
status: EntityStatus
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBrandRequest {
|
export interface CreateBrandRequest {
|
||||||
@@ -160,16 +206,49 @@ export interface UpdateBrandRequest {
|
|||||||
name: string
|
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
|
name: string
|
||||||
|
status: EntityStatus
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVariantCategoryRequest {
|
export interface CreateItemTypeRequest {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVariantCategoryRequest {
|
export interface UpdateItemTypeRequest {
|
||||||
name: string
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the
|
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09).
|
||||||
// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see
|
// Mirrors the live Dtos/Procurement/*.cs — the API contract is the source of truth.
|
||||||
// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock.
|
|
||||||
|
|
||||||
// --- 3.1 Requisitions --------------------------------------------------------------
|
// --- 3.1 Requisitions --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -10,7 +9,8 @@ export interface ReqLine {
|
|||||||
reqLineId: number
|
reqLineId: number
|
||||||
itemId: number
|
itemId: number
|
||||||
qty: number
|
qty: number
|
||||||
requiredBy: string
|
/** Date only, `YYYY-MM-DD`. */
|
||||||
|
requiredBy: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Requisition {
|
export interface Requisition {
|
||||||
@@ -34,7 +34,7 @@ export interface RequisitionSummary {
|
|||||||
export interface CreateReqLineInput {
|
export interface CreateReqLineInput {
|
||||||
itemId: number
|
itemId: number
|
||||||
qty: number
|
qty: number
|
||||||
requiredBy: string
|
requiredBy?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRequisitionRequest {
|
export interface CreateRequisitionRequest {
|
||||||
@@ -43,8 +43,6 @@ export interface CreateRequisitionRequest {
|
|||||||
|
|
||||||
// --- 3.2 RFQs & Quotations ----------------------------------------------------------
|
// --- 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 type RfqStatus = "Open" | "Closed"
|
||||||
|
|
||||||
export interface RfqLine {
|
export interface RfqLine {
|
||||||
@@ -53,23 +51,26 @@ export interface RfqLine {
|
|||||||
qty: number
|
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 {
|
export interface Rfq {
|
||||||
rfqId: number
|
rfqId: number
|
||||||
docNo: string
|
docNo: string
|
||||||
requisitionId: number | null
|
requisitionId: number
|
||||||
status: RfqStatus
|
status: RfqStatus
|
||||||
vendorIds: number[]
|
|
||||||
createdAt: string
|
|
||||||
lines: RfqLine[]
|
lines: RfqLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RfqSummary {
|
export interface RfqSummary {
|
||||||
rfqId: number
|
rfqId: number
|
||||||
docNo: string
|
docNo: string
|
||||||
requisitionId: number | null
|
requisitionId: number
|
||||||
status: RfqStatus
|
status: RfqStatus
|
||||||
vendorIds: number[]
|
lineCount: number
|
||||||
createdAt: string
|
quotationCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRfqLineInput {
|
export interface CreateRfqLineInput {
|
||||||
@@ -78,7 +79,8 @@ export interface CreateRfqLineInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRfqRequest {
|
export interface CreateRfqRequest {
|
||||||
requisitionId?: number | null
|
/** Required — an RFQ is always raised against a requisition. */
|
||||||
|
requisitionId: number
|
||||||
vendorIds: number[]
|
vendorIds: number[]
|
||||||
lines: CreateRfqLineInput[]
|
lines: CreateRfqLineInput[]
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,6 @@ export interface Quotation {
|
|||||||
quotationId: number
|
quotationId: number
|
||||||
rfqId: number
|
rfqId: number
|
||||||
vendorId: number
|
vendorId: number
|
||||||
createdAt: string
|
|
||||||
lines: QuotationLine[]
|
lines: QuotationLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,20 +105,21 @@ export interface CreateQuotationRequest {
|
|||||||
|
|
||||||
export interface RfqComparisonCell {
|
export interface RfqComparisonCell {
|
||||||
vendorId: number
|
vendorId: number
|
||||||
|
quotationId: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
leadDays: number
|
leadDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RfqComparisonLine {
|
export interface RfqComparisonRow {
|
||||||
itemId: number
|
itemId: number
|
||||||
qty: number
|
qty: number
|
||||||
cells: RfqComparisonCell[]
|
quotes: RfqComparisonCell[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RfqComparison {
|
export interface RfqComparison {
|
||||||
rfqId: number
|
rfqId: number
|
||||||
vendorIds: number[]
|
vendorIds: number[]
|
||||||
lines: RfqComparisonLine[]
|
rows: RfqComparisonRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3.3 Purchase Orders -------------------------------------------------------------
|
// --- 3.3 Purchase Orders -------------------------------------------------------------
|
||||||
@@ -138,6 +140,7 @@ export interface PoLine {
|
|||||||
warehouseId: number
|
warehouseId: number
|
||||||
qty: number
|
qty: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
|
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
||||||
tax: number
|
tax: number
|
||||||
qtyReceived: number
|
qtyReceived: number
|
||||||
}
|
}
|
||||||
@@ -192,16 +195,17 @@ export interface CreatePurchaseOrderRequest {
|
|||||||
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
|
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
|
||||||
|
|
||||||
export interface CancelPurchaseOrderRequest {
|
export interface CancelPurchaseOrderRequest {
|
||||||
reason: string
|
reason?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3.4 Purchase Returns -------------------------------------------------------------
|
// --- 3.4 Purchase Returns -------------------------------------------------------------
|
||||||
|
|
||||||
export type PurchaseReturnStatus = "Posted"
|
export type PurchaseReturnStatus = "Draft" | "Posted"
|
||||||
|
|
||||||
export interface PurchaseReturnLine {
|
export interface PurchaseReturnLine {
|
||||||
returnLineId: number
|
returnLineId: number
|
||||||
grnLineId: number
|
/** Optional: a return may reference the originating GRN line for traceability. */
|
||||||
|
grnLineId: number | null
|
||||||
itemId: number
|
itemId: number
|
||||||
qty: number
|
qty: number
|
||||||
}
|
}
|
||||||
@@ -226,11 +230,13 @@ export interface PurchaseReturnSummary {
|
|||||||
warehouseId: number
|
warehouseId: number
|
||||||
reasonCodeId: number
|
reasonCodeId: number
|
||||||
status: PurchaseReturnStatus
|
status: PurchaseReturnStatus
|
||||||
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
lineCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePurchaseReturnLineInput {
|
export interface CreatePurchaseReturnLineInput {
|
||||||
grnLineId: number
|
grnLineId?: number | null
|
||||||
itemId: number
|
itemId: number
|
||||||
qty: number
|
qty: number
|
||||||
}
|
}
|
||||||
@@ -238,6 +244,7 @@ export interface CreatePurchaseReturnLineInput {
|
|||||||
export interface CreatePurchaseReturnRequest {
|
export interface CreatePurchaseReturnRequest {
|
||||||
vendorId: number
|
vendorId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
|
/** Mandatory (FR-PROC-08); omitting it returns 400 REASON_CODE_REQUIRED. */
|
||||||
reasonCodeId: number
|
reasonCodeId: number
|
||||||
lines: CreatePurchaseReturnLineInput[]
|
lines: CreatePurchaseReturnLineInput[]
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user