Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b9ee8f6c | |||
| 4108062416 | |||
| 03bc85b788 | |||
| 9158cd8c82 | |||
| 951961b798 | |||
| f02c89b3cb | |||
| 295ec5799f | |||
| fe9e8a780f | |||
| 92c4b14a6c | |||
| 80b130dffb |
@@ -29,3 +29,10 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# New EF Core migrations are not committed. Note the 4 migrations already in
|
||||
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
|
||||
# not apply to tracked files — so edits to those still get committed as normal.
|
||||
# Untracking them too takes `git rm --cached`.
|
||||
**/Migrations/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -25,12 +26,27 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IAuthUserService _users;
|
||||
private readonly IAuthRecoveryService _recovery;
|
||||
private readonly IAuthAltService _alt;
|
||||
private readonly IRoleService _roles;
|
||||
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
_roles = roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative current-session info for the frontend: role + the sidebar nav
|
||||
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
|
||||
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||
{
|
||||
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||
}
|
||||
|
||||
// ---- Session-issuing (UserManager) ------------------------------------
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
|
||||
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
|
||||
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
|
||||
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
|
||||
/// </summary>
|
||||
[Route("api/v1/nav")]
|
||||
public sealed class NavController : ApiControllerBase
|
||||
{
|
||||
private readonly IRepository<NavItem> _navItems;
|
||||
|
||||
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
|
||||
{
|
||||
var items = await _navItems.Query().AsNoTracking()
|
||||
.Include(n => n.Children)
|
||||
.OrderBy(n => n.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dto = items.Select(n => new NavItemDto(
|
||||
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
|
||||
n.Children.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
|
||||
.ToList())).ToList();
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
[HttpPost("{poId:int}/submit")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
|
||||
=> Ok(await _pos.SubmitAsync(poId, ct));
|
||||
|
||||
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||
[HttpDelete("{poId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
|
||||
{
|
||||
await _pos.DeleteAsync(poId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
||||
[HttpPost("{poId:int}/approve")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||
[Route("api/v1/roles")]
|
||||
public sealed class RolesController : ApiControllerBase
|
||||
{
|
||||
private readonly IRoleService _roles;
|
||||
|
||||
public RolesController(IRoleService roles) => _roles = roles;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _roles.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{roleId:int}")]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
|
||||
{
|
||||
var result = await _roles.GetAsync(roleId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _roles.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{roleId:int}")]
|
||||
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{roleId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _roles.SetStatusAsync(roleId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{roleId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
|
||||
{
|
||||
await _roles.DeleteAsync(roleId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{roleId:int}/permissions")]
|
||||
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
|
||||
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
|
||||
|
||||
[HttpPut("{roleId:int}/permissions")]
|
||||
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
|
||||
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User management: local shadow `User` list/detail + role assignment, and
|
||||
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||
/// </summary>
|
||||
[Route("api/v1/users")]
|
||||
public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
|
||||
public UsersController(IUserManagementService users) => _users = users;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, ct));
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
[HttpGet("user-types")]
|
||||
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
|
||||
=> Ok(await _users.ListUserTypesAsync(ct));
|
||||
|
||||
[HttpGet("{userId:int}")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.GetAsync(userId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/users/{result.UserId}", result);
|
||||
}
|
||||
|
||||
[HttpPut("{userId:int}/role")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||
}
|
||||
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
|
||||
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
|
||||
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
|
||||
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
|
||||
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
|
||||
/// snapshots the PO price at receipt so the variance survives later PO edits.
|
||||
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
|
||||
/// costs at (VAT never enters stock value; it is recoverable input tax).
|
||||
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
|
||||
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
|
||||
/// </summary>
|
||||
public class GrnLine
|
||||
@@ -31,7 +35,30 @@ public class GrnLine
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
|
||||
public decimal? PoUnitPrice { get; set; }
|
||||
|
||||
/// <summary>Trade discount percentage (0–100), entered.</summary>
|
||||
public decimal DiscountPct { get; set; }
|
||||
|
||||
/// <summary>UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
|
||||
public decimal NetUnitCost { get; set; }
|
||||
|
||||
/// <summary>VAT percentage (0–100), entered. Recoverable — does not affect stock value.</summary>
|
||||
public decimal VatPct { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
|
||||
public decimal VatAmount { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
|
||||
public decimal ReceivedValue { get; set; }
|
||||
|
||||
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
|
||||
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
|
||||
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
|
||||
/// not by editing these rows through the UI.
|
||||
/// </summary>
|
||||
public class NavItem
|
||||
{
|
||||
public int NavItemId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Icon { get; set; }
|
||||
public string? Href { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
|
||||
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
|
||||
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
|
||||
/// <see cref="RolePermission"/> grants it to a role.
|
||||
/// </summary>
|
||||
public class Permission
|
||||
{
|
||||
public int PermissionId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public int? NavItemId { get; set; }
|
||||
public int? SubNavItemId { get; set; }
|
||||
|
||||
public NavItem? NavItem { get; set; }
|
||||
public SubNavItem? SubNavItem { get; set; }
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class PoLine
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal UnitPrice { get; set; }//
|
||||
public decimal Tax { get; set; }
|
||||
public decimal QtyReceived { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
|
||||
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
|
||||
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
|
||||
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
|
||||
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
|
||||
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
|
||||
/// </summary>
|
||||
public class Role
|
||||
{
|
||||
public int RoleId { get; set; }
|
||||
public Guid AuthRoleId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsSystemRole { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
||||
public class RolePermission
|
||||
{
|
||||
public int RoleId { get; set; }
|
||||
public int PermissionId { get; set; }
|
||||
|
||||
public Role? Role { get; set; }
|
||||
public Permission? Permission { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>A child sidebar entry under a <see cref="NavItem"/> (e.g. Products' children).</summary>
|
||||
public class SubNavItem
|
||||
{
|
||||
public int SubNavItemId { get; set; }
|
||||
public int NavItemId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Icon { get; set; }
|
||||
public string? Href { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public NavItem? NavItem { get; set; }
|
||||
}
|
||||
@@ -22,4 +22,8 @@ public class User
|
||||
|
||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||
public Guid? AuthUserId { get; set; }
|
||||
|
||||
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
|
||||
public int? RoleId { get; set; }
|
||||
public Role? Role { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
|
||||
public sealed class AuthHexRoleDto
|
||||
{
|
||||
public Guid RoleId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public bool? IsSystemRole { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateAuthHexRoleRequest
|
||||
{
|
||||
[Required] public string Code { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public bool? IsSystemRole { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateAuthHexRoleRequest
|
||||
{
|
||||
[Required] public Guid RoleId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public bool? IsSystemRole { get; set; }
|
||||
}
|
||||
@@ -82,6 +82,14 @@ public sealed class GetUserDetailsResponse
|
||||
public JsonElement? UserType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).</summary>
|
||||
public sealed class UserTypeDto
|
||||
{
|
||||
public Guid UserTypeId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SessionDto
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn;
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
|
||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||
HoldStatus HoldStatus, int? BatchId);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
@@ -44,8 +47,16 @@ public sealed class CreateGrnLineInput
|
||||
[Required] public int UomId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
|
||||
/// <summary>
|
||||
/// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional
|
||||
/// per-receipt price override — when 0/omitted the PO line price is used; when supplied it
|
||||
/// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised).
|
||||
/// </summary>
|
||||
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
|
||||
/// <summary>Trade discount percentage (0–100). Reduces the inventory cost.</summary>
|
||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
||||
/// <summary>VAT percentage (0–100). Recoverable — does not affect stock value.</summary>
|
||||
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
|
||||
[Required] public int VendorId { get; set; }
|
||||
public int? RequisitionId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When true the PO is created in <c>Draft</c> (editable/deletable, not yet issued).
|
||||
/// When false (default) it auto-approves on creation, preserving the Requisition→PO
|
||||
/// and RFQ→PO flows unchanged (docs/11 §3.3).
|
||||
/// </summary>
|
||||
public bool SaveAsDraft { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdatePurchaseOrderRequest
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ERPCore.Dtos.Rbac;
|
||||
|
||||
/// <summary>Response for `GET /api/v1/auth/me` — the frontend's authoritative source
|
||||
/// for the current user's role and permitted sidebar nav codes (replaces trusting
|
||||
/// the stale, client-only `roleId` cached in localStorage).</summary>
|
||||
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Dtos.Rbac;
|
||||
|
||||
public sealed record SubNavItemDto(int SubNavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder);
|
||||
|
||||
public sealed record NavItemDto(
|
||||
int NavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder,
|
||||
IReadOnlyList<SubNavItemDto> Children);
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Rbac;
|
||||
|
||||
public sealed record RoleDto(
|
||||
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
public sealed class CreateRoleRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateRoleRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateRoleStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Replaces a role's full permission set (checkbox-tree save from the frontend).</summary>
|
||||
public sealed class AssignRolePermissionsRequest
|
||||
{
|
||||
public List<int> NavItemIds { get; set; } = new();
|
||||
public List<int> SubNavItemIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed record RolePermissionsDto(int RoleId, List<int> NavItemIds, List<int> SubNavItemIds);
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Users;
|
||||
|
||||
public sealed record ManagedUserDto(
|
||||
int UserId, string Username, string DisplayName, EntityStatus Status,
|
||||
int? RoleId, string? RoleCode, string? RoleName);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user in both backends: forwards to AuthHex's `registerUser` (source
|
||||
/// of truth for credentials), then mirrors the account into ERPCore's local
|
||||
/// shadow `User` row immediately (rather than waiting for next-login JIT
|
||||
/// provisioning). AuthHex emails the generated/supplied password to `Email`.
|
||||
/// </summary>
|
||||
public sealed class CreateUserRequest
|
||||
{
|
||||
[Required, StringLength(100)] public string Username { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
|
||||
[Required] public int RoleId { get; set; }
|
||||
[Required] public Guid UserTypeId { get; set; }
|
||||
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
|
||||
public string? Nic { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRoleRequest
|
||||
{
|
||||
[Required] public int RoleId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
||||
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
@@ -16,9 +17,14 @@ namespace ERPCore.Infra.Auth.AuthHex;
|
||||
/// </summary>
|
||||
public sealed class AuthHexClient : IAuthHexClient
|
||||
{
|
||||
// WhenWritingNull: AuthHex's dispatcher reads payload fields as raw JsonElements and some
|
||||
// (e.g. RoleManager's isSystemRole) call type-specific getters like GetBoolean() that throw
|
||||
// on an explicit JSON null rather than treating it as "absent" — omit null properties instead
|
||||
// of serializing them, so unset nullable request fields behave as ContainsKey == false upstream.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
@@ -42,6 +48,9 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
|
||||
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
|
||||
|
||||
public Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct)
|
||||
=> CallAsync<List<UserTypeDto>>("user", "listUserTypes", new { }, null, ct);
|
||||
|
||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
|
||||
|
||||
@@ -103,6 +112,23 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
||||
|
||||
// ---- RoleManager --------------------------------------------------
|
||||
|
||||
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
|
||||
|
||||
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
|
||||
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
|
||||
|
||||
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
|
||||
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
|
||||
|
||||
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
|
||||
|
||||
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
|
||||
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
|
||||
|
||||
// ---- Transport --------------------------------------------------------
|
||||
|
||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IAuthHexClient
|
||||
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
|
||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
|
||||
Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct);
|
||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
|
||||
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
|
||||
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
|
||||
@@ -39,4 +40,12 @@ public interface IAuthHexClient
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
||||
|
||||
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
|
||||
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
|
||||
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
|
||||
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
|
||||
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
|
||||
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
|
||||
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
|
||||
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
|
||||
builder.Property(l => l.NetUnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.VatPct).HasPrecision(9, 4);
|
||||
builder.Property(l => l.VatAmount).HasPrecision(18, 4);
|
||||
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
|
||||
builder.Property(l => l.LineTotal).HasPrecision(18, 4);
|
||||
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
|
||||
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Seeded to mirror the frontend's hardcoded sidebar
|
||||
/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here
|
||||
/// must match the <c>code</c> given to each frontend nav entry.
|
||||
/// </summary>
|
||||
public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NavItem> builder)
|
||||
{
|
||||
builder.ToTable("nav_items");
|
||||
builder.HasKey(n => n.NavItemId);
|
||||
|
||||
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(n => n.Code).IsUnique();
|
||||
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
|
||||
builder.Property(n => n.Icon).HasMaxLength(50);
|
||||
builder.Property(n => n.Href).HasMaxLength(200);
|
||||
builder.Property(n => n.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.HasData(
|
||||
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
|
||||
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
|
||||
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
|
||||
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
|
||||
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
|
||||
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
|
||||
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
|
||||
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
|
||||
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="NavItem"/>/<see cref="SubNavItem"/>, seeded in lockstep
|
||||
/// with <see cref="NavItemConfiguration"/>/<see cref="SubNavItemConfiguration"/>.
|
||||
/// </summary>
|
||||
public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Permission> builder)
|
||||
{
|
||||
builder.ToTable("permissions");
|
||||
builder.HasKey(p => p.PermissionId);
|
||||
|
||||
builder.Property(p => p.Code).IsRequired().HasMaxLength(80);
|
||||
builder.HasIndex(p => p.Code).IsUnique();
|
||||
|
||||
builder.HasOne(p => p.NavItem).WithMany()
|
||||
.HasForeignKey(p => p.NavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(p => p.SubNavItem).WithMany()
|
||||
.HasForeignKey(p => p.SubNavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasData(
|
||||
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
|
||||
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
|
||||
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
|
||||
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
|
||||
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
|
||||
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
|
||||
new Permission { PermissionId = 7, Code = "NAV:warehouses", NavItemId = 7 },
|
||||
new Permission { PermissionId = 8, Code = "NAV:orders", NavItemId = 8 },
|
||||
new Permission { PermissionId = 9, Code = "NAV:settings", NavItemId = 9 },
|
||||
new Permission { PermissionId = 10, Code = "NAV:help", NavItemId = 10 },
|
||||
new Permission { PermissionId = 11, Code = "NAV:products.item", SubNavItemId = 1 },
|
||||
new Permission { PermissionId = 12, Code = "NAV:products.category", SubNavItemId = 2 },
|
||||
new Permission { PermissionId = 13, Code = "NAV:products.brand", SubNavItemId = 3 },
|
||||
new Permission { PermissionId = 14, Code = "NAV:products.item-type", SubNavItemId = 4 },
|
||||
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
|
||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Role> builder)
|
||||
{
|
||||
builder.ToTable("roles");
|
||||
builder.HasKey(r => r.RoleId);
|
||||
|
||||
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
|
||||
builder.HasIndex(r => r.AuthRoleId).IsUnique();
|
||||
|
||||
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(r => r.Code).IsUnique();
|
||||
|
||||
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
|
||||
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.Property(r => r.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(r => r.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(r => r.RowVersion).IsRowVersion();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class RolePermissionConfiguration : IEntityTypeConfiguration<RolePermission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RolePermission> builder)
|
||||
{
|
||||
builder.ToTable("role_permissions");
|
||||
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
|
||||
|
||||
builder.HasOne(rp => rp.Role).WithMany()
|
||||
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(rp => rp.Permission).WithMany()
|
||||
.HasForeignKey(rp => rp.PermissionId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SubNavItem> builder)
|
||||
{
|
||||
builder.ToTable("sub_nav_items");
|
||||
builder.HasKey(n => n.SubNavItemId);
|
||||
|
||||
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(n => n.Code).IsUnique();
|
||||
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
|
||||
builder.Property(n => n.Icon).HasMaxLength(50);
|
||||
builder.Property(n => n.Href).HasMaxLength(200);
|
||||
builder.Property(n => n.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.HasOne(n => n.NavItem).WithMany(n => n.Children)
|
||||
.HasForeignKey(n => n.NavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasData(
|
||||
new SubNavItem { SubNavItemId = 1, NavItemId = 2, Code = "products.item", Label = "Item", Href = "/dashboard/products", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 2, NavItemId = 2, Code = "products.category", Label = "Category", Href = "/dashboard/products/categories", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 3, NavItemId = 2, Code = "products.brand", Label = "Brand", Href = "/dashboard/products/brands", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
|
||||
builder.HasIndex(u => u.AuthUserId).IsUnique();
|
||||
|
||||
// Local shadow Role assignment (nullable — unset until an admin assigns one).
|
||||
builder.HasOne(u => u.Role).WithMany()
|
||||
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Seeded fallback audit actor while auth is deferred (§6).
|
||||
builder.HasData(new User
|
||||
{
|
||||
|
||||
@@ -41,6 +41,13 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
|
||||
|
||||
// --- RBAC / sidebar (docs/10 Part C.8) ---
|
||||
public DbSet<Role> Roles => Set<Role>();
|
||||
public DbSet<NavItem> NavItems => Set<NavItem>();
|
||||
public DbSet<SubNavItem> SubNavItems => Set<SubNavItem>();
|
||||
public DbSet<Permission> Permissions => Set<Permission>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
// --- Procurement (docs/10 Part C.2) ---
|
||||
public DbSet<Requisition> Requisitions => Set<Requisition>();
|
||||
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
|
||||
|
||||
+2454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ini2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3001
File diff suppressed because it is too large
Load Diff
+303
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolesNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RoleId",
|
||||
table: "users",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "roles",
|
||||
columns: table => new
|
||||
{
|
||||
RoleId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_roles", x => x.RoleId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sub_nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sub_nav_items_nav_items_NavItemId",
|
||||
column: x => x.NavItemId,
|
||||
principalTable: "nav_items",
|
||||
principalColumn: "NavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "permissions",
|
||||
columns: table => new
|
||||
{
|
||||
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: true),
|
||||
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_permissions", x => x.PermissionId);
|
||||
table.ForeignKey(
|
||||
name: "FK_permissions_nav_items_NavItemId",
|
||||
column: x => x.NavItemId,
|
||||
principalTable: "nav_items",
|
||||
principalColumn: "NavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_permissions_sub_nav_items_SubNavItemId",
|
||||
column: x => x.SubNavItemId,
|
||||
principalTable: "sub_nav_items",
|
||||
principalColumn: "SubNavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "role_permissions",
|
||||
columns: table => new
|
||||
{
|
||||
RoleId = table.Column<int>(type: "integer", nullable: false),
|
||||
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
|
||||
table.ForeignKey(
|
||||
name: "FK_role_permissions_permissions_PermissionId",
|
||||
column: x => x.PermissionId,
|
||||
principalTable: "permissions",
|
||||
principalColumn: "PermissionId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_role_permissions_roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "roles",
|
||||
principalColumn: "RoleId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
|
||||
{ 2, "products", "/dashboard/products", null, "Products", 2 },
|
||||
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
|
||||
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
|
||||
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
|
||||
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
|
||||
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
|
||||
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
|
||||
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
||||
{ 10, "help", "/dashboard/help", null, "Help", 10 }
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "users",
|
||||
keyColumn: "UserId",
|
||||
keyValue: 1,
|
||||
column: "RoleId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "NAV:dashboard", 1, null },
|
||||
{ 2, "NAV:products", 2, null },
|
||||
{ 3, "NAV:vendors", 3, null },
|
||||
{ 4, "NAV:procurement", 4, null },
|
||||
{ 5, "NAV:receiving", 5, null },
|
||||
{ 6, "NAV:stock", 6, null },
|
||||
{ 7, "NAV:warehouses", 7, null },
|
||||
{ 8, "NAV:orders", 8, null },
|
||||
{ 9, "NAV:settings", 9, null },
|
||||
{ 10, "NAV:help", 10, null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
|
||||
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
|
||||
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
|
||||
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
|
||||
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
|
||||
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
|
||||
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
|
||||
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 11, "NAV:products.item", null, 1 },
|
||||
{ 12, "NAV:products.category", null, 2 },
|
||||
{ 13, "NAV:products.brand", null, 3 },
|
||||
{ 14, "NAV:products.item-type", null, 4 },
|
||||
{ 15, "NAV:products.uom", null, 5 },
|
||||
{ 16, "NAV:products.configuration", null, 6 },
|
||||
{ 17, "NAV:settings.roles", null, 7 },
|
||||
{ 18, "NAV:settings.users", null, 8 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_users_RoleId",
|
||||
table: "users",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_nav_items_Code",
|
||||
table: "nav_items",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_Code",
|
||||
table: "permissions",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_NavItemId",
|
||||
table: "permissions",
|
||||
column: "NavItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_SubNavItemId",
|
||||
table: "permissions",
|
||||
column: "SubNavItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_role_permissions_PermissionId",
|
||||
table: "role_permissions",
|
||||
column: "PermissionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_roles_auth_role_id",
|
||||
table: "roles",
|
||||
column: "auth_role_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_roles_Code",
|
||||
table: "roles",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sub_nav_items_Code",
|
||||
table: "sub_nav_items",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sub_nav_items_NavItemId",
|
||||
table: "sub_nav_items",
|
||||
column: "NavItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_users_roles_RoleId",
|
||||
table: "users",
|
||||
column: "RoleId",
|
||||
principalTable: "roles",
|
||||
principalColumn: "RoleId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_users_roles_RoleId",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "role_permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "roles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sub_nav_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "nav_items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_users_RoleId",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RoleId",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,10 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Property<int?>("BinId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("DiscountPct")
|
||||
.HasPrecision(9, 4)
|
||||
.HasColumnType("numeric(9,4)");
|
||||
|
||||
b.Property<int>("GrnId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("LineTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("NetUnitCost")
|
||||
.HasPrecision(18, 6)
|
||||
.HasColumnType("numeric(18,6)");
|
||||
|
||||
b.Property<int?>("PoLineId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal?>("PoUnitPrice")
|
||||
.HasPrecision(18, 6)
|
||||
.HasColumnType("numeric(18,6)");
|
||||
|
||||
b.Property<decimal>("Qty")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Property<int>("UomId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VatAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("VatPct")
|
||||
.HasPrecision(9, 4)
|
||||
.HasColumnType("numeric(9,4)");
|
||||
|
||||
b.HasKey("GrnLineId");
|
||||
|
||||
b.HasIndex("BatchId");
|
||||
@@ -524,6 +548,142 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("journal_entry_stubs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||
{
|
||||
b.Property<int>("NavItemId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NavItemId"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Href")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.HasKey("NavItemId");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("nav_items", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
NavItemId = 1,
|
||||
Code = "dashboard",
|
||||
Href = "/dashboard",
|
||||
Label = "Dashboard",
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 2,
|
||||
Code = "products",
|
||||
Href = "/dashboard/products",
|
||||
Label = "Products",
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 3,
|
||||
Code = "vendors",
|
||||
Href = "/dashboard/vendors",
|
||||
Label = "Vendors",
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 4,
|
||||
Code = "procurement",
|
||||
Href = "/dashboard/procurement",
|
||||
Label = "Procurement",
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 5,
|
||||
Code = "receiving",
|
||||
Href = "/dashboard/receiving/grn",
|
||||
Label = "Receiving",
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 6,
|
||||
Code = "stock",
|
||||
Href = "/dashboard/stock",
|
||||
Label = "Stock",
|
||||
SortOrder = 6,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 7,
|
||||
Code = "warehouses",
|
||||
Href = "/dashboard/warehouse",
|
||||
Label = "Warehouses",
|
||||
SortOrder = 7,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 8,
|
||||
Code = "orders",
|
||||
Href = "/dashboard/orders",
|
||||
Label = "Orders",
|
||||
SortOrder = 8,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 9,
|
||||
Code = "settings",
|
||||
Href = "/dashboard/settings",
|
||||
Label = "Settings",
|
||||
SortOrder = 9,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 10,
|
||||
Code = "help",
|
||||
Href = "/dashboard/help",
|
||||
Label = "Help",
|
||||
SortOrder = 10,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
|
||||
{
|
||||
b.Property<int>("SequenceId")
|
||||
@@ -554,6 +714,171 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("number_sequences", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
|
||||
{
|
||||
b.Property<int>("PermissionId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PermissionId"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.Property<int?>("NavItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SubNavItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("PermissionId");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NavItemId");
|
||||
|
||||
b.HasIndex("SubNavItemId");
|
||||
|
||||
b.ToTable("permissions", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
PermissionId = 1,
|
||||
Code = "NAV:dashboard",
|
||||
NavItemId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 2,
|
||||
Code = "NAV:products",
|
||||
NavItemId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 3,
|
||||
Code = "NAV:vendors",
|
||||
NavItemId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 4,
|
||||
Code = "NAV:procurement",
|
||||
NavItemId = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 5,
|
||||
Code = "NAV:receiving",
|
||||
NavItemId = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 6,
|
||||
Code = "NAV:stock",
|
||||
NavItemId = 6
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 7,
|
||||
Code = "NAV:warehouses",
|
||||
NavItemId = 7
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 8,
|
||||
Code = "NAV:orders",
|
||||
NavItemId = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 9,
|
||||
Code = "NAV:settings",
|
||||
NavItemId = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 10,
|
||||
Code = "NAV:help",
|
||||
NavItemId = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 11,
|
||||
Code = "NAV:products.item",
|
||||
SubNavItemId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 12,
|
||||
Code = "NAV:products.category",
|
||||
SubNavItemId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 13,
|
||||
Code = "NAV:products.brand",
|
||||
SubNavItemId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 14,
|
||||
Code = "NAV:products.item-type",
|
||||
SubNavItemId = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 15,
|
||||
Code = "NAV:products.uom",
|
||||
SubNavItemId = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 16,
|
||||
Code = "NAV:products.configuration",
|
||||
SubNavItemId = 6
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 17,
|
||||
Code = "NAV:settings.roles",
|
||||
SubNavItemId = 7
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 18,
|
||||
Code = "NAV:settings.users",
|
||||
SubNavItemId = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 19,
|
||||
Code = "NAV:procurement.requisitions",
|
||||
SubNavItemId = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 20,
|
||||
Code = "NAV:procurement.rfqs",
|
||||
SubNavItemId = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 21,
|
||||
Code = "NAV:procurement.purchase-orders",
|
||||
SubNavItemId = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 22,
|
||||
Code = "NAV:procurement.purchase-returns",
|
||||
SubNavItemId = 12
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
||||
{
|
||||
b.Property<int>("PoLineId")
|
||||
@@ -942,6 +1267,78 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("rfq_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Role", b =>
|
||||
{
|
||||
b.Property<int>("RoleId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RoleId"));
|
||||
|
||||
b.Property<Guid>("AuthRoleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("auth_role_id");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsSystemRole")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("RoleId");
|
||||
|
||||
b.HasIndex("AuthRoleId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
|
||||
{
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PermissionId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("RoleId", "PermissionId");
|
||||
|
||||
b.HasIndex("PermissionId");
|
||||
|
||||
b.ToTable("role_permissions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||
{
|
||||
b.Property<int>("SerialId")
|
||||
@@ -1439,6 +1836,177 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("subcategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
|
||||
{
|
||||
b.Property<int>("SubNavItemId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubNavItemId"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Href")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("NavItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.HasKey("SubNavItemId");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NavItemId");
|
||||
|
||||
b.ToTable("sub_nav_items", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
SubNavItemId = 1,
|
||||
Code = "products.item",
|
||||
Href = "/dashboard/products",
|
||||
Label = "Item",
|
||||
NavItemId = 2,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 2,
|
||||
Code = "products.category",
|
||||
Href = "/dashboard/products/categories",
|
||||
Label = "Category",
|
||||
NavItemId = 2,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 3,
|
||||
Code = "products.brand",
|
||||
Href = "/dashboard/products/brands",
|
||||
Label = "Brand",
|
||||
NavItemId = 2,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 4,
|
||||
Code = "products.item-type",
|
||||
Href = "/dashboard/products/item-types",
|
||||
Label = "Item Type",
|
||||
NavItemId = 2,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 5,
|
||||
Code = "products.uom",
|
||||
Href = "/dashboard/products/uoms",
|
||||
Label = "UOM",
|
||||
NavItemId = 2,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 6,
|
||||
Code = "products.configuration",
|
||||
Href = "/dashboard/products/settings",
|
||||
Label = "Configuration",
|
||||
NavItemId = 2,
|
||||
SortOrder = 6,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 7,
|
||||
Code = "settings.roles",
|
||||
Href = "/dashboard/settings/roles",
|
||||
Label = "Roles",
|
||||
NavItemId = 9,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 8,
|
||||
Code = "settings.users",
|
||||
Href = "/dashboard/settings/users",
|
||||
Label = "Users",
|
||||
NavItemId = 9,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 9,
|
||||
Code = "procurement.requisitions",
|
||||
Href = "/dashboard/procurement/requisitions",
|
||||
Label = "Requisitions",
|
||||
NavItemId = 4,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 10,
|
||||
Code = "procurement.rfqs",
|
||||
Href = "/dashboard/procurement/rfqs",
|
||||
Label = "RFQs",
|
||||
NavItemId = 4,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 11,
|
||||
Code = "procurement.purchase-orders",
|
||||
Href = "/dashboard/procurement/purchase-orders",
|
||||
Label = "Purchase Orders",
|
||||
NavItemId = 4,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 12,
|
||||
Code = "procurement.purchase-returns",
|
||||
Href = "/dashboard/procurement/purchase-returns",
|
||||
Label = "Purchase Returns",
|
||||
NavItemId = 4,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
||||
{
|
||||
b.Property<int>("UomId")
|
||||
@@ -1510,6 +2078,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -1525,6 +2096,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.HasIndex("AuthUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
@@ -1857,6 +2430,23 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("NavItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubNavItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("NavItem");
|
||||
|
||||
b.Navigation("SubNavItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -2049,6 +2639,25 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Rfq");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Permission", "Permission")
|
||||
.WithMany()
|
||||
.HasForeignKey("PermissionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Permission");
|
||||
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -2317,6 +2926,17 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("NavItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NavItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
||||
@@ -2344,6 +2964,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("ToUom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
|
||||
@@ -2399,6 +3029,11 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("UomConversions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
@@ -68,6 +68,10 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
|
||||
// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management
|
||||
builder.Services.AddScoped<IRoleService, RoleService>();
|
||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||
|
||||
// Cross-cutting + procurement services (docs/11 §3)
|
||||
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
|
||||
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
|
||||
|
||||
@@ -138,8 +138,11 @@ public sealed class GrnService : IGrnService
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
|
||||
// Cost: for a PO line, the PO price is used unless an override is entered (then it
|
||||
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
|
||||
// revised). Direct receipts always use the entered cost.
|
||||
decimal unitCost;
|
||||
decimal? poUnitPrice = null;
|
||||
if (input.PoLineId is not null)
|
||||
{
|
||||
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||
@@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
|
||||
unitCost = poLine.UnitPrice;
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
unitCost = input.UnitCost;
|
||||
}
|
||||
|
||||
// Derived figures are always computed server-side, never accepted from the client.
|
||||
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
|
||||
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
@@ -170,7 +179,13 @@ public sealed class GrnService : IGrnService
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
UnitCost = unitCost,
|
||||
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
NetUnitCost = netUnitCost,
|
||||
VatPct = input.VatPct,
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
});
|
||||
}
|
||||
@@ -220,7 +235,9 @@ public sealed class GrnService : IGrnService
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
|
||||
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
|
||||
// enters stock value (docs/10 FR-GRN-06, revised).
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
@@ -380,5 +397,8 @@ public sealed class GrnService : IGrnService
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
|
||||
l.HoldStatus, l.BatchId)).ToList());
|
||||
}
|
||||
|
||||
@@ -16,4 +16,10 @@ public interface IPurchaseOrderService
|
||||
Task<ETagged<PurchaseOrderDto>> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default);
|
||||
Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE.</summary>
|
||||
Task DeleteAsync(int poId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Role CRUD + permission assignment. AuthHex is the source of truth for Role
|
||||
/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is
|
||||
/// forwarded to AuthHex first, then mirrored into the local shadow <c>Role</c> row.
|
||||
/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one).
|
||||
/// </summary>
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default);
|
||||
Task DeleteAsync(int roleId, CancellationToken ct = default);
|
||||
|
||||
Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default);
|
||||
Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`.</summary>
|
||||
Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-facing user management: list/create/reassign-role against the local
|
||||
/// shadow `User` table, orchestrating account creation in AuthHex too (see
|
||||
/// <see cref="CreateUserRequest"/>).
|
||||
/// </summary>
|
||||
public interface IUserManagementService
|
||||
{
|
||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
DocNo = docNo,
|
||||
VendorId = request.VendorId,
|
||||
RequisitionId = request.RequisitionId,
|
||||
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04).
|
||||
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04),
|
||||
// unless the caller explicitly saves a Draft (editable/deletable until submitted).
|
||||
ApprovalRequired = false,
|
||||
Status = PurchaseOrderStatus.Approved,
|
||||
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(ToLine).ToList()
|
||||
@@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is not (
|
||||
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled);
|
||||
public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
|
||||
|
||||
// Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04).
|
||||
po.Status = PurchaseOrderStatus.Approved;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409);
|
||||
|
||||
_pos.Remove(po);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it.
|
||||
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
|
||||
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class RoleService : IRoleService
|
||||
{
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IRepository<RolePermission> _rolePermissions;
|
||||
private readonly IRepository<Permission> _permissions;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IAuthHexClient authHex, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_authHex = authHex;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _roles.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(r => r.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(r => r.Code)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<RoleDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId, ct);
|
||||
return role is null ? null : new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (await _roles.Query().AnyAsync(r => r.Code == code, ct))
|
||||
throw new ConflictException($"A role with code '{code}' already exists.");
|
||||
|
||||
var authRole = await _authHex.CreateRoleAsync(
|
||||
new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct);
|
||||
|
||||
var role = new Role
|
||||
{
|
||||
AuthRoleId = authRole.RoleId,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
IsSystemRole = authRole.IsSystemRole ?? false,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _roles.AddAsync(role, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>> UpdateAsync(
|
||||
int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
if (role.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
|
||||
|
||||
var code = request.Code.Trim();
|
||||
if (!string.Equals(role.Code, code, StringComparison.Ordinal)
|
||||
&& await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct))
|
||||
throw new ConflictException($"A role with code '{code}' already exists.");
|
||||
|
||||
await _authHex.UpdateRoleAsync(
|
||||
new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct);
|
||||
|
||||
role.Code = code;
|
||||
role.Name = request.Name.Trim();
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
role.Status = status;
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
|
||||
}
|
||||
catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409);
|
||||
}
|
||||
|
||||
_roles.Remove(role);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
var granted = await _rolePermissions.Query().AsNoTracking()
|
||||
.Where(rp => rp.RoleId == roleId)
|
||||
.Include(rp => rp.Permission)
|
||||
.Select(rp => rp.Permission!)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new RolePermissionsDto(
|
||||
roleId,
|
||||
granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(),
|
||||
granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList());
|
||||
}
|
||||
|
||||
public async Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
var existing = await _rolePermissions.Query().Where(rp => rp.RoleId == roleId).ToListAsync(ct);
|
||||
foreach (var rp in existing) _rolePermissions.Remove(rp);
|
||||
|
||||
var navIds = request.NavItemIds.Distinct().ToList();
|
||||
var subNavIds = request.SubNavItemIds.Distinct().ToList();
|
||||
|
||||
var permissionIds = await _permissions.Query().AsNoTracking()
|
||||
.Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value))
|
||||
|| (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value)))
|
||||
.Select(p => p.PermissionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var permissionId in permissionIds)
|
||||
await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct);
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return await GetPermissionsAsync(roleId, ct);
|
||||
}
|
||||
|
||||
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleCode))
|
||||
return new MeResponseDto(null, null, Array.Empty<string>());
|
||||
|
||||
var role = await _roles.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Code == roleCode, ct);
|
||||
if (role is null)
|
||||
return new MeResponseDto(roleCode, null, Array.Empty<string>());
|
||||
|
||||
var permissions = await _rolePermissions.Query().AsNoTracking()
|
||||
.Where(rp => rp.RoleId == role.RoleId)
|
||||
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
|
||||
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
|
||||
.Select(rp => rp.Permission!)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var navCodes = permissions
|
||||
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
|
||||
.Where(code => code is not null)
|
||||
.Select(code => code!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return new MeResponseDto(role.Code, role.Name, navCodes);
|
||||
}
|
||||
|
||||
private static RoleDto Map(Role r) => new(
|
||||
r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class UserManagementService : IUserManagementService
|
||||
{
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IAuthUserService _authUsers;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public UserManagementService(
|
||||
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_authUsers = authUsers;
|
||||
_authHex = authHex;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(u => u.Username)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ManagedUserDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.Query().AsNoTracking().Include(u => u.Role)
|
||||
.FirstOrDefaultAsync(u => u.UserId == userId, ct);
|
||||
return user is null ? null : Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(request.RoleId, ct)
|
||||
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
|
||||
|
||||
var username = request.Username.Trim();
|
||||
if (await _users.Query().AnyAsync(u => u.Username == username, ct))
|
||||
throw new ConflictException($"A user with username '{username}' already exists.");
|
||||
|
||||
var authUserId = Guid.NewGuid();
|
||||
|
||||
// Source of truth: AuthHex creates the credential + emails it (registerUser,
|
||||
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
|
||||
await _authUsers.RegisterAsync(new RegisterRequest
|
||||
{
|
||||
UserId = authUserId,
|
||||
RoleId = role.AuthRoleId,
|
||||
UserTypeId = request.UserTypeId,
|
||||
Fullname = request.FullName.Trim(),
|
||||
UserName = username,
|
||||
Nic = request.Nic,
|
||||
Email = request.Email.Trim(),
|
||||
MobileNumber = request.MobileNumber,
|
||||
Password = request.Password,
|
||||
ChkUser = true
|
||||
}, ct);
|
||||
|
||||
// Mirror into the local shadow User row immediately, rather than waiting
|
||||
// for ShadowUserClaimsTransformation's next-login JIT provisioning.
|
||||
var user = new User
|
||||
{
|
||||
AuthUserId = authUserId,
|
||||
Username = username,
|
||||
DisplayName = request.FullName.Trim(),
|
||||
RoleId = role.RoleId,
|
||||
Status = EntityStatus.Active
|
||||
};
|
||||
|
||||
await _users.AddAsync(user, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
user.Role = role;
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.GetByIdAsync(userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
var role = await _roles.GetByIdAsync(request.RoleId, ct)
|
||||
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
|
||||
|
||||
user.RoleId = role.RoleId;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
user.Role = role;
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||
return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList();
|
||||
}
|
||||
|
||||
private static ManagedUserDto Map(User u) => new(
|
||||
u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public static class ErrorCodes
|
||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
||||
public const string ConfigDisabled = "CONFIG_DISABLED";
|
||||
public const string RoleInUse = "ROLE_IN_USE";
|
||||
|
||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
|
||||
},
|
||||
"Auth": {
|
||||
"Issuer": "AuthHex",
|
||||
@@ -16,7 +16,7 @@
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "CHANGE_ME"
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
+12
-2
@@ -55,7 +55,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
|
||||
- [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
|
||||
- [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
|
||||
- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
|
||||
- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open)
|
||||
- [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.
|
||||
@@ -74,7 +74,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
|
||||
## 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.
|
||||
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
|
||||
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry.
|
||||
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
|
||||
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
|
||||
|
||||
@@ -111,6 +111,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`)
|
||||
- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value).
|
||||
- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched.
|
||||
- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly.
|
||||
- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010.
|
||||
|
||||
### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`)
|
||||
- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 9–12, `NavItemId 4`) + 4 `Permission`s (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows.
|
||||
- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean.
|
||||
|
||||
### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented
|
||||
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services.
|
||||
- Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor.
|
||||
|
||||
+10
-2
@@ -39,12 +39,12 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
## 3. Procurement screens
|
||||
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01
|
||||
- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry.
|
||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
||||
|
||||
## 4. Receiving screens
|
||||
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`
|
||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry.
|
||||
- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session
|
||||
- [~] 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`
|
||||
@@ -90,6 +90,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass)
|
||||
- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived).
|
||||
- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages.
|
||||
- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong.
|
||||
- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `<SelectValue>` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source.
|
||||
- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording.
|
||||
- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar"
|
||||
import { Header } from "@/components/Layouts/Header"
|
||||
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -9,22 +10,24 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
<AuthProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
description: "Auto-approved on creation, freely editable while open, cancellable before receipt.",
|
||||
description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.",
|
||||
href: "/dashboard/procurement/purchase-orders",
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
const [showCancelForm, setShowCancelForm] = useState(false)
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
function toDraftLines(order: PurchaseOrder): DraftLine[] {
|
||||
return order.lines.map((l) => ({
|
||||
@@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitPo() {
|
||||
if (!po) return
|
||||
setSaveError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!po) return
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setSaveError(null)
|
||||
setDeleting(true)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
toast.success("Draft deleted", po.docNo)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not delete purchase order", errorMessage(err))
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!po) return
|
||||
if (!cancelReason.trim()) {
|
||||
@@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
|
||||
const editable = isPoEditable(po.status) && !conflict
|
||||
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0)
|
||||
// A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason;
|
||||
// a Draft is deleted instead, and closed/cancelled POs are terminal.
|
||||
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPoEditable(po.status) && !showCancelForm && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={() => setShowCancelForm(true)}
|
||||
disabled={hasReceipts}
|
||||
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
||||
>
|
||||
<Ban className="size-5" />
|
||||
Cancel PO
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
{deleting ? "Deleting…" : "Delete draft"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{cancellable && !showCancelForm && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={() => setShowCancelForm(true)}
|
||||
disabled={hasReceipts}
|
||||
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
||||
>
|
||||
<Ban className="size-5" />
|
||||
Cancel PO
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCancelForm && (
|
||||
|
||||
@@ -43,8 +43,12 @@ function newKey() {
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
||||
// from an RFQ keeps its negotiated price (below).
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" }
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
}
|
||||
|
||||
function NewPurchaseOrderContent() {
|
||||
@@ -98,8 +102,8 @@ function NewPurchaseOrderContent() {
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: "",
|
||||
tax: "0.18",
|
||||
unitPrice: "0",
|
||||
tax: "0",
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -124,8 +128,8 @@ function NewPurchaseOrderContent() {
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: cell ? String(cell.unitPrice) : "",
|
||||
tax: "0.18",
|
||||
unitPrice: cell ? String(cell.unitPrice) : "0",
|
||||
tax: "0",
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -151,7 +155,7 @@ function NewPurchaseOrderContent() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(saveAsDraft: boolean) {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
@@ -197,8 +201,12 @@ function NewPurchaseOrderContent() {
|
||||
vendorId,
|
||||
requisitionId: requisitionId ?? (rfqId ? undefined : null),
|
||||
lines: payloadLines,
|
||||
saveAsDraft,
|
||||
})
|
||||
toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`)
|
||||
toast.success(
|
||||
"Purchase order created",
|
||||
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).`
|
||||
)
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
@@ -274,8 +282,6 @@ function NewPurchaseOrderContent() {
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Tax</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -348,30 +354,6 @@ function NewPurchaseOrderContent() {
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
@@ -394,8 +376,11 @@ function NewPurchaseOrderContent() {
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create PO"}
|
||||
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
|
||||
{submitting ? "Saving…" : "Save as draft"}
|
||||
</Button>
|
||||
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create & submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
|
||||
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -21,30 +20,9 @@ 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 ReorderDraft {
|
||||
key: string
|
||||
warehouseId: number | null
|
||||
reorderPoint: string
|
||||
reorderQty: string
|
||||
}
|
||||
|
||||
interface ConversionDraft {
|
||||
key: string
|
||||
fromUom: number | null
|
||||
toUom: number | null
|
||||
factor: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `row-${keySeq}`
|
||||
}
|
||||
|
||||
export default function ItemDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
@@ -54,13 +32,13 @@ export default function ItemDetailPage() {
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Basic info form
|
||||
const [sku, setSku] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
|
||||
const [description, setDescription] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
|
||||
@@ -68,10 +46,15 @@ export default function ItemDetailPage() {
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
const [brandId, setBrandId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
// Default vendor, tax class, and tracking mode are no longer editable on this page —
|
||||
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
const [taxClass, setTaxClass] = useState("")
|
||||
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||
// isn't sent on save — nothing to wire it to server-side.
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
@@ -79,18 +62,6 @@ export default function ItemDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
// Reorder settings
|
||||
const [reorderLines, setReorderLines] = useState<ReorderDraft[]>([])
|
||||
const [reorderErrors, setReorderErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [reorderSaveError, setReorderSaveError] = useState<string | null>(null)
|
||||
const [savingReorder, setSavingReorder] = useState(false)
|
||||
|
||||
// UOM conversions
|
||||
const [conversionLines, setConversionLines] = useState<ConversionDraft[]>([])
|
||||
const [conversionErrors, setConversionErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [conversionSaveError, setConversionSaveError] = useState<string | null>(null)
|
||||
const [savingConversions, setSavingConversions] = useState(false)
|
||||
|
||||
function applyItem(data: Item) {
|
||||
setItem(data)
|
||||
setSku(data.sku)
|
||||
@@ -104,8 +75,6 @@ export default function ItemDetailPage() {
|
||||
setStockNature(data.stockNature)
|
||||
setTrackingMode(data.trackingMode)
|
||||
setTaxClass(data.taxClass ?? "")
|
||||
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
|
||||
setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
||||
}
|
||||
|
||||
function load() {
|
||||
@@ -123,11 +92,10 @@ export default function ItemDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
load()
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([cat, uo, ve, wh]) => {
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([cat, uo, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
setVendors(ve.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -183,83 +151,6 @@ export default function ItemDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateReorderLine(key: string, patch: Partial<ReorderDraft>) {
|
||||
setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
function removeReorderLine(key: string) {
|
||||
setReorderLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSaveReorder() {
|
||||
if (!item) return
|
||||
setReorderSaveError(null)
|
||||
const nextErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of reorderLines) {
|
||||
const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty })
|
||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
||||
}
|
||||
setReorderErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setReorderSaveError("Fix the highlighted rows before saving.")
|
||||
return
|
||||
}
|
||||
|
||||
const settings: ItemReorderSetting[] = reorderLines.map((l) => ({
|
||||
warehouseId: l.warehouseId as number,
|
||||
reorderPoint: Number(l.reorderPoint),
|
||||
reorderQty: Number(l.reorderQty),
|
||||
}))
|
||||
|
||||
setSavingReorder(true)
|
||||
try {
|
||||
const result = await itemsApi.updateReorder(item.itemId, { settings })
|
||||
setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev))
|
||||
toast.success("Reorder settings saved")
|
||||
} catch (err) {
|
||||
setReorderSaveError(errorMessage(err))
|
||||
toast.error("Could not save reorder settings", errorMessage(err))
|
||||
} finally {
|
||||
setSavingReorder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateConversionLine(key: string, patch: Partial<ConversionDraft>) {
|
||||
setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
function removeConversionLine(key: string) {
|
||||
setConversionLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSaveConversions() {
|
||||
if (!item) return
|
||||
setConversionSaveError(null)
|
||||
const nextErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of conversionLines) {
|
||||
const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor })
|
||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
||||
}
|
||||
setConversionErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setConversionSaveError("Fix the highlighted rows before saving.")
|
||||
return
|
||||
}
|
||||
|
||||
const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) }))
|
||||
|
||||
setSavingConversions(true)
|
||||
try {
|
||||
const result = await itemsApi.updateUomConversions(item.itemId, { conversions })
|
||||
setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev))
|
||||
setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
||||
toast.success("UOM conversions saved")
|
||||
} catch (err) {
|
||||
setConversionSaveError(errorMessage(err))
|
||||
toast.error("Could not save UOM conversions", errorMessage(err))
|
||||
} finally {
|
||||
setSavingConversions(false)
|
||||
}
|
||||
}
|
||||
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
@@ -339,13 +230,14 @@ export default function ItemDetailPage() {
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Description</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
disabled={conflict}
|
||||
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
@@ -361,7 +253,12 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
|
||||
<Select<number | null>
|
||||
value={baseUomId}
|
||||
onValueChange={setBaseUomId}
|
||||
disabled={conflict}
|
||||
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
||||
<SelectValue placeholder="Select base UOM" />
|
||||
</SelectTrigger>
|
||||
@@ -375,25 +272,6 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Default vendor</Label>
|
||||
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax class</Label>
|
||||
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||
stock-nature one it used to be confused with (docs/11 §8). */}
|
||||
@@ -410,15 +288,22 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}>
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
disabled={conflict}
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -434,165 +319,8 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Per-warehouse reorder point and quantity (FR-MD-05).</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setReorderLines((prev) => [...prev, { key: newKey(), warehouseId: null, reorderPoint: "", reorderQty: "" }])}>
|
||||
<Plus className="size-5" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{reorderLines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reorder qty</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reorderLines.map((line) => {
|
||||
const errs = reorderErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.warehouseId ? { message: errs.warehouseId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.reorderPoint} aria-invalid={!!errs.reorderPoint} onChange={(e) => updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.reorderPoint ? { message: errs.reorderPoint } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.reorderQty} aria-invalid={!!errs.reorderQty} onChange={(e) => updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.reorderQty ? { message: errs.reorderQty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeReorderLine(line.key)} aria-label="Remove row">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{reorderSaveError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{reorderSaveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleSaveReorder} disabled={savingReorder}>
|
||||
{savingReorder ? "Saving…" : "Save reorder settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">UOM conversions</h2>
|
||||
<p className="text-sm text-muted-foreground">Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setConversionLines((prev) => [...prev, { key: newKey(), fromUom: null, toUom: item.baseUomId, factor: "" }])}>
|
||||
<Plus className="size-5" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversionLines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">From UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">To UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Factor</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conversionLines.map((line) => {
|
||||
const errs = conversionErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.fromUom}>
|
||||
<SelectValue placeholder="From" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.fromUom ? { message: errs.fromUom } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.toUom}>
|
||||
<SelectValue placeholder="To" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.toUom ? { message: errs.toUom } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.factor} aria-invalid={!!errs.factor} onChange={(e) => updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.factor ? { message: errs.factor } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeConversionLine(line.key)} aria-label="Remove row">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{conversionSaveError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{conversionSaveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleSaveConversions} disabled={savingConversions}>
|
||||
{savingConversions ? "Saving…" : "Save conversions"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."}
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||
import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateBrandName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "brandId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
@@ -33,8 +35,12 @@ export default function BrandsPage() {
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Brand | null>(null)
|
||||
@@ -50,12 +56,12 @@ export default function BrandsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, sortOrder])
|
||||
}, [search, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
brandsApi
|
||||
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setBrands(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -63,9 +69,30 @@ export default function BrandsPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, sortOrder, page])
|
||||
useEffect(load, [search, status, page])
|
||||
|
||||
const hasFilters = search.trim().length > 0
|
||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||
|
||||
const sortedBrands = brands
|
||||
? [...brands].sort((a, b) => {
|
||||
const cmp =
|
||||
sortKey === "brandId"
|
||||
? a.brandId - b.brandId
|
||||
: sortKey === "createdAt"
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: a[sortKey].localeCompare(b[sortKey])
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
: null
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
@@ -176,13 +203,14 @@ export default function BrandsPage() {
|
||||
aria-label="Search brands"
|
||||
/>
|
||||
</div>
|
||||
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc" className="text-base">Name (A–Z)</SelectItem>
|
||||
<SelectItem value="desc" className="text-base">Name (Z–A)</SelectItem>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -203,7 +231,7 @@ export default function BrandsPage() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Tag className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No brands match your search." : "No brands yet."}
|
||||
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,15 +241,23 @@ export default function BrandsPage() {
|
||||
<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">
|
||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{brands.map((b) => (
|
||||
{sortedBrands!.map((b) => (
|
||||
<TableRow key={b.brandId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||
@@ -313,3 +349,35 @@ export default function BrandsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
@@ -33,8 +35,12 @@ export default function CategoriesPage() {
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
@@ -50,12 +56,12 @@ export default function CategoriesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, sortOrder])
|
||||
}, [search, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
categoriesApi
|
||||
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setCategories(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -63,9 +69,30 @@ export default function CategoriesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, sortOrder, page])
|
||||
useEffect(load, [search, status, page])
|
||||
|
||||
const hasFilters = search.trim().length > 0
|
||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||
|
||||
const sortedCategories = categories
|
||||
? [...categories].sort((a, b) => {
|
||||
const cmp =
|
||||
sortKey === "categoryId"
|
||||
? a.categoryId - b.categoryId
|
||||
: sortKey === "createdAt"
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: a[sortKey].localeCompare(b[sortKey])
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
: null
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
@@ -175,13 +202,14 @@ export default function CategoriesPage() {
|
||||
aria-label="Search categories"
|
||||
/>
|
||||
</div>
|
||||
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc" className="text-base">Name (A–Z)</SelectItem>
|
||||
<SelectItem value="desc" className="text-base">Name (Z–A)</SelectItem>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -202,7 +230,7 @@ export default function CategoriesPage() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ListTree className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No categories match your search." : "No categories yet."}
|
||||
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -212,15 +240,23 @@ export default function CategoriesPage() {
|
||||
<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">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map((c) => (
|
||||
{sortedCategories!.map((c) => (
|
||||
<TableRow key={c.categoryId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
@@ -320,3 +356,35 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import { brandsApi } from "@/lib/api/brands"
|
||||
import { itemTypesApi } from "@/lib/api/item-types"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
|
||||
import { validateVariantItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
|
||||
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -36,26 +37,11 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
|
||||
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour is special-cased by name. This stays a frontend concern: item types are names
|
||||
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
|
||||
*/
|
||||
function isColorCategory(categoryName: string): boolean {
|
||||
return categoryName.trim().toLowerCase() === "color"
|
||||
}
|
||||
|
||||
function encodeColorValue(name: string, hex: string): string {
|
||||
return `${name}|${hex}`
|
||||
}
|
||||
|
||||
function decodeColorValue(value: string): { name: string; hex: string } {
|
||||
const separatorIndex = value.indexOf("|")
|
||||
if (separatorIndex === -1) return { name: value, hex: "#d4d4d8" }
|
||||
return { name: value.slice(0, separatorIndex), hex: value.slice(separatorIndex + 1) }
|
||||
}
|
||||
|
||||
function partLabel(part: { name: string; value: string }): string {
|
||||
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
|
||||
/** The item builder only ever offers these two dimensions, regardless of what else exists
|
||||
* in the Item Types master list. */
|
||||
const BUILDER_ITEM_TYPES = ["color", "size"]
|
||||
function isBuilderItemType(name: string): boolean {
|
||||
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
export default function NewItemPage() {
|
||||
@@ -65,29 +51,27 @@ export default function NewItemPage() {
|
||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
/**
|
||||
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
|
||||
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
|
||||
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
|
||||
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
|
||||
*/
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
/** Defaults to the first UOM once loaded; null only means none exist yet. */
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
const [brandId, setBrandId] = useState<number | null>(null)
|
||||
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||
// isn't sent on submit — nothing to wire it to server-side.
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
|
||||
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
|
||||
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
||||
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
||||
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
||||
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState("")
|
||||
const [newCategoryError, setNewCategoryError] = useState<string | null>(null)
|
||||
const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false)
|
||||
// Lets a specific generated combination be dropped from the preview table before
|
||||
// submit, without having to remove and re-add the whole value that produced it.
|
||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
@@ -99,14 +83,17 @@ export default function NewItemPage() {
|
||||
brandsApi.list({ pageSize: 200, status: "Active" }),
|
||||
itemTypesApi.list({ pageSize: 200, status: "Active" }),
|
||||
productConfig(),
|
||||
uomsApi.list({ pageSize: 1 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([cat, br, types, cfg, uoms]) => {
|
||||
.then(([cat, br, types, cfg, uoms, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setBrands(br.items)
|
||||
setItemTypes(types.items)
|
||||
setConfig(cfg)
|
||||
setUoms(uoms.items)
|
||||
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
@@ -141,30 +128,8 @@ export default function NewItemPage() {
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAddItemType() {
|
||||
const nextErrors = validateItemTypeName(newCategoryName)
|
||||
if (nextErrors.name) {
|
||||
setNewCategoryError(nextErrors.name)
|
||||
return
|
||||
}
|
||||
setAddingCategorySubmitting(true)
|
||||
try {
|
||||
const created = await itemTypesApi.create({ name: newCategoryName })
|
||||
setItemTypes((prev) => [...(prev ?? []), created.data])
|
||||
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
|
||||
setNewCategoryName("")
|
||||
setNewCategoryError(null)
|
||||
setAddingCategory(false)
|
||||
toast.success("Item type created", created.data.name)
|
||||
} catch (err) {
|
||||
setNewCategoryError(errorMessage(err))
|
||||
} finally {
|
||||
setAddingCategorySubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function addValue(itemTypeId: number, overrideValue?: string) {
|
||||
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
|
||||
function addValue(itemTypeId: number) {
|
||||
const value = (inputByCategory[itemTypeId] ?? "").trim()
|
||||
if (value) {
|
||||
setValuesByCategory((prev) => {
|
||||
const existing = prev[itemTypeId] ?? []
|
||||
@@ -191,7 +156,7 @@ export default function NewItemPage() {
|
||||
[itemTypes, checkedItemTypeIds, valuesByCategory]
|
||||
)
|
||||
|
||||
const variants = useMemo(() => {
|
||||
const allVariants = useMemo(() => {
|
||||
if (activeCategories.length === 0) return []
|
||||
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
|
||||
for (const cat of activeCategories) {
|
||||
@@ -208,10 +173,19 @@ export default function NewItemPage() {
|
||||
}
|
||||
return combinations.map((c) => ({
|
||||
...c,
|
||||
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
|
||||
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
|
||||
}))
|
||||
}, [activeCategories, effectiveLabel])
|
||||
|
||||
const variants = useMemo(
|
||||
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
|
||||
[allVariants, removedVariantKeys]
|
||||
)
|
||||
|
||||
function removeVariant(key: string) {
|
||||
setRemovedVariantKeys((prev) => new Set(prev).add(key))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||
@@ -228,7 +202,7 @@ export default function NewItemPage() {
|
||||
for (const variant of variants) {
|
||||
await itemsApi.create({
|
||||
sku: variant.sku,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
|
||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||
// category, which lost the parent entirely. The server rejects a mismatched
|
||||
// pair with 422.
|
||||
@@ -236,7 +210,7 @@ export default function NewItemPage() {
|
||||
subCategoryId,
|
||||
brandId,
|
||||
baseUomId,
|
||||
stockNature: "Stocked",
|
||||
stockNature,
|
||||
trackingMode: "None",
|
||||
})
|
||||
created += 1
|
||||
@@ -294,7 +268,11 @@ export default function NewItemPage() {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={handleCategoryChange}>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={handleCategoryChange}
|
||||
items={(categories ?? []).map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
@@ -313,7 +291,12 @@ export default function NewItemPage() {
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
|
||||
<Select<number | null>
|
||||
value={subCategoryId}
|
||||
onValueChange={setSubCategoryId}
|
||||
disabled={subCategories.length === 0}
|
||||
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||
</SelectTrigger>
|
||||
@@ -330,7 +313,11 @@ export default function NewItemPage() {
|
||||
{config?.brandsEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
||||
<Select<number | null>
|
||||
value={brandId}
|
||||
onValueChange={setBrandId}
|
||||
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select brand" />
|
||||
</SelectTrigger>
|
||||
@@ -344,6 +331,57 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<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 className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null>
|
||||
value={baseUomId}
|
||||
onValueChange={setBaseUomId}
|
||||
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select base UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||
@@ -358,67 +396,19 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{(itemTypes ?? []).map((t) => (
|
||||
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||
/>
|
||||
<span className="text-base font-medium">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{!addingCategory && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Add another item type"
|
||||
onClick={() => setAddingCategory(true)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(itemTypes ?? [])
|
||||
.filter((t) => isBuilderItemType(t.name))
|
||||
.map((t) => (
|
||||
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||
/>
|
||||
<span className="text-base font-medium">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addingCategory && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddItemType()
|
||||
}
|
||||
}}
|
||||
placeholder="Material"
|
||||
className="h-11 max-w-xs text-base"
|
||||
aria-invalid={!!newCategoryError}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Cancel"
|
||||
onClick={() => {
|
||||
setAddingCategory(false)
|
||||
setNewCategoryName("")
|
||||
setNewCategoryError(null)
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[newCategoryError ? { message: newCategoryError } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
||||
|
||||
{checkedItemTypeIds.length > 0 && (
|
||||
@@ -426,86 +416,43 @@ export default function NewItemPage() {
|
||||
{(itemTypes ?? [])
|
||||
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||
.map((t) => {
|
||||
const isColor = isColorCategory(t.name)
|
||||
const currentInput = inputByCategory[t.itemTypeId] ?? ""
|
||||
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
|
||||
|
||||
function addColor() {
|
||||
const name = currentColorName.trim()
|
||||
if (!name) return
|
||||
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
|
||||
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||
<Label className="text-base">{t.name} values</Label>
|
||||
<div className="flex gap-2">
|
||||
{isColor ? (
|
||||
<>
|
||||
<input
|
||||
type="color"
|
||||
value={currentInput || "#EF4444"}
|
||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
|
||||
aria-label="Pick color"
|
||||
/>
|
||||
<Input
|
||||
value={currentColorName}
|
||||
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addColor()
|
||||
}
|
||||
}}
|
||||
placeholder="Color name (e.g. Red)"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
value={currentInput}
|
||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addValue(t.itemTypeId)
|
||||
}
|
||||
}}
|
||||
placeholder={t.name}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
|
||||
<Input
|
||||
value={currentInput}
|
||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addValue(t.itemTypeId)
|
||||
}
|
||||
}}
|
||||
placeholder={t.name}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add {t.name}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
|
||||
const decoded = isColor ? decodeColorValue(v) : null
|
||||
return (
|
||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
||||
{decoded && (
|
||||
<span
|
||||
className="size-3.5 shrink-0 rounded-full border border-black/10"
|
||||
style={{ backgroundColor: decoded.hex }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{decoded ? decoded.name : v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeValue(t.itemTypeId, v)}
|
||||
className="rounded-full p-0.5 hover:bg-muted"
|
||||
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
|
||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeValue(t.itemTypeId, v)}
|
||||
className="rounded-full p-0.5 hover:bg-muted"
|
||||
aria-label={`Remove ${v}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -526,29 +473,30 @@ export default function NewItemPage() {
|
||||
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. */}
|
||||
<TableHead className="h-11 w-8 px-1" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variants.map((variant) => (
|
||||
<TableRow key={variant.key}>
|
||||
{variant.parts.map((part, i) => {
|
||||
const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null
|
||||
return (
|
||||
<TableCell key={i} className="px-3 py-2.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{decoded && (
|
||||
<span
|
||||
className="size-3.5 shrink-0 rounded-full border border-black/10"
|
||||
style={{ backgroundColor: decoded.hex }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{partLabel(part)}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
|
||||
{variant.parts.map((part, i) => (
|
||||
<TableCell key={i} className="px-3 py-2.5">
|
||||
{part.value}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||
<TableCell className="py-2.5 pr-3 pl-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeVariant(variant.key)}
|
||||
aria-label={`Remove ${variant.sku}`}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Sear
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category, ItemListItem, TrackingMode } from "@/types/master-data"
|
||||
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -26,6 +28,9 @@ const PAGE_SIZE = 10
|
||||
export default function ItemsPage() {
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -64,12 +69,33 @@ export default function ItemsPage() {
|
||||
useEffect(load, [page, query, status, categoryId, trackingMode])
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
productConfig().then(setConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// No "list all subcategories" endpoint exists — they're scoped per category — so once
|
||||
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) return
|
||||
Promise.all(
|
||||
categories.map((c) =>
|
||||
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
|
||||
)
|
||||
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
|
||||
}, [categories])
|
||||
|
||||
function categoryName(id: number) {
|
||||
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
function subCategoryName(id: number) {
|
||||
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
function brandName(id: number) {
|
||||
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
|
||||
|
||||
return (
|
||||
@@ -106,7 +132,11 @@ export default function ItemsPage() {
|
||||
aria-label="Search items"
|
||||
/>
|
||||
</div>
|
||||
<Select<number | "All"> value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}>
|
||||
<Select<number | "All">
|
||||
value={categoryId}
|
||||
onValueChange={(v) => setCategoryId(v ?? "All")}
|
||||
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
@@ -175,6 +205,8 @@ export default function ItemsPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
|
||||
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
@@ -191,6 +223,16 @@ export default function ItemsPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{item.brandId !== null ? brandName(item.brandId) : "—"}
|
||||
</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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Info } from "lucide-react"
|
||||
import { ArrowLeft, Package } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -17,11 +17,10 @@ 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.
|
||||
* `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
|
||||
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
|
||||
* `itemTypesEnabled` has no control on this screen but is still part of the record, so
|
||||
* every save round-trips its current value unchanged (the server rejects a partial body).
|
||||
*/
|
||||
export default function ProductSettingsPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
@@ -42,7 +41,7 @@ export default function ProductSettingsPage() {
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
|
||||
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
|
||||
if (!config) return
|
||||
setSaving(flag)
|
||||
try {
|
||||
@@ -90,32 +89,29 @@ export default function ProductSettingsPage() {
|
||||
|
||||
{!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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||
<ToggleRow
|
||||
label="Subcategories"
|
||||
description="Category hierarchy includes a subcategory level. Off ⇒ products 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."
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Brands"
|
||||
description="Products may carry a brand."
|
||||
checked={config.brandsEnabled}
|
||||
busy={saving === "brandsEnabled"}
|
||||
onChange={(v) => toggle("brandsEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
@@ -132,7 +128,6 @@ export default function ProductSettingsPage() {
|
||||
const LABELS: Record<string, string> = {
|
||||
subcategoriesEnabled: "Subcategories",
|
||||
brandsEnabled: "Brands",
|
||||
itemTypesEnabled: "Item types",
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
@@ -141,28 +136,26 @@ function ToggleRow({
|
||||
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 items-start gap-4 py-4">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={busy}
|
||||
aria-label={label}
|
||||
className="mt-1 shrink-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -165,8 +166,12 @@ export default function GrnDetailPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received value</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Unit cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Net cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Received value</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">VAT</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
|
||||
</TableRow>
|
||||
@@ -180,8 +185,22 @@ export default function GrnDetailPage() {
|
||||
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.unitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{line.unitCost.toFixed(2)}
|
||||
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
|
||||
<span className="block text-xs text-warning">
|
||||
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.discountPct.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.netUnitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.receivedValue.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{line.vatAmount.toFixed(2)}
|
||||
<span className="block text-xs text-muted-foreground">{line.vatPct.toFixed(2)}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</TableCell>
|
||||
@@ -228,6 +247,22 @@ export default function GrnDetailPage() {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-8 border-t border-border pt-4 text-base">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
|
||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">VAT</span>
|
||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Document total</span>
|
||||
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,12 +37,28 @@ interface DraftLine {
|
||||
binId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
/** PO line price when prefilled from a PO; drives the variance hint. */
|
||||
poUnitPrice: number | null
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
holdStatus: HoldStatus
|
||||
batchNo: string
|
||||
expiryDate: string
|
||||
serialNumbersText: string
|
||||
}
|
||||
|
||||
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
|
||||
function computeLine(l: DraftLine) {
|
||||
const qty = Number(l.qty) || 0
|
||||
const gross = Number(l.unitCost) || 0
|
||||
const disc = Number(l.discountPct) || 0
|
||||
const vat = Number(l.vatPct) || 0
|
||||
const netUnitCost = gross * (1 - disc / 100)
|
||||
const receivedValue = qty * netUnitCost
|
||||
const vatAmount = receivedValue * (vat / 100)
|
||||
return { netUnitCost, receivedValue, vatAmount, lineTotal: receivedValue + vatAmount }
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
@@ -58,6 +74,9 @@ function emptyLine(): DraftLine {
|
||||
binId: null,
|
||||
qty: "",
|
||||
unitCost: "",
|
||||
poUnitPrice: null,
|
||||
discountPct: "0",
|
||||
vatPct: "0",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
@@ -152,6 +171,9 @@ export default function NewGrnPage() {
|
||||
binId: null,
|
||||
qty: String(l.qty - l.qtyReceived),
|
||||
unitCost: String(l.unitPrice),
|
||||
poUnitPrice: l.unitPrice,
|
||||
discountPct: "0",
|
||||
vatPct: "0",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
@@ -211,6 +233,8 @@ export default function NewGrnPage() {
|
||||
uomId: line.uomId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
|
||||
batchNo: line.batchNo,
|
||||
serialNumbersText: line.serialNumbersText,
|
||||
@@ -232,6 +256,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
|
||||
@@ -371,16 +397,20 @@ export default function NewGrnPage() {
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<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-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-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-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -473,6 +503,50 @@ export default function NewGrnPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
@@ -533,6 +607,16 @@ export default function NewGrnPage() {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NavItem, Role } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
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 { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function RoleDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const roleId = Number(params.id)
|
||||
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
Promise.all([rolesApi.get(roleId), navApi.tree(), rolesApi.getPermissions(roleId)])
|
||||
.then(([roleResult, navTree, permissions]) => {
|
||||
setRole(roleResult.data)
|
||||
setEtag(roleResult.etag)
|
||||
setCode(roleResult.data.code)
|
||||
setName(roleResult.data.name)
|
||||
setTree(navTree)
|
||||
setNavItemIds(new Set(permissions.navItemIds))
|
||||
setSubNavItemIds(new Set(permissions.subNavItemIds))
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(roleId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roleId])
|
||||
|
||||
async function handleSave() {
|
||||
setSaveError(null)
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Role code is required"
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0 || !etag) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await rolesApi.update(roleId, { code: code.trim().toUpperCase(), name: name.trim() }, etag)
|
||||
setRole(result.data)
|
||||
setEtag(result.etag)
|
||||
toast.success("Role saved", `${result.data.code} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const errCode = (err as { code?: string })?.code
|
||||
if (errCode === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save role", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
setSavingPermissions(true)
|
||||
try {
|
||||
await rolesApi.assignPermissions(roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
toast.success("Permissions saved", "This role's visible sidebar sections have been updated.")
|
||||
} catch (err) {
|
||||
toast.error("Could not save permissions", errorMessage(err))
|
||||
} finally {
|
||||
setSavingPermissions(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !role) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to roles
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!role || !tree) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{role.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{role.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This role was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Code</Label>
|
||||
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving…" : "Save details"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||
</div>
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
|
||||
<Save className="size-4" />
|
||||
{savingPermissions ? "Saving…" : "Save permissions"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => router.push("/dashboard/settings/roles")}>
|
||||
Back to roles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { NavItem, Role } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||
function deriveCode(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
rolesApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setRoles(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(() => {
|
||||
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setName("")
|
||||
setNavItemIds(new Set())
|
||||
setSubNavItemIds(new Set())
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const code = deriveCode(name)
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await rolesApi.create({ code, name: name.trim() })
|
||||
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||
await rolesApi.assignPermissions(result.data.roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
}
|
||||
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not create role", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(role: Role) {
|
||||
setActionPendingId(role.roleId)
|
||||
try {
|
||||
await rolesApi.remove(role.roleId)
|
||||
toast.success("Role deleted", `${role.code} has been removed.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete role", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Manage roles and which sidebar sections each one can see.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Role
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New role</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="r-name">Name</FieldLabel>
|
||||
<Input id="r-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="r-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="r-code" value={code} disabled readOnly placeholder="—" className="text-muted-foreground" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FieldLabel>Sidebar permissions</FieldLabel>
|
||||
{tree === null ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "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 && roles === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No roles yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((r) => (
|
||||
<TableRow key={r.roleId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${r.code}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${r.code}?`}
|
||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(r)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const userId = Number(params.id)
|
||||
|
||||
const [user, setUser] = useState<ManagedUser | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([u, roleList]) => {
|
||||
setUser(u)
|
||||
setRoles(roleList.items)
|
||||
setRoleId(u.roleId ? String(u.roleId) : "")
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(userId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!roleId) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await usersApi.updateRole(userId, { roleId: Number(roleId) })
|
||||
setUser(result)
|
||||
toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not update role", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to users
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
user.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:max-w-sm">
|
||||
<Label className="text-base">Role</Label>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/settings/users")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || !roleId}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save role"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { ManagedUser, UserTypeOption } from "@/types/users"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [fullName, setFullName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [mobileNumber, setMobileNumber] = useState("")
|
||||
const [nic, setNic] = useState("")
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [userTypeId, setUserTypeId] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
usersApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(() => {
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.userTypes()
|
||||
.then((types) => {
|
||||
setUserTypes(types)
|
||||
// Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it
|
||||
// so the admin never has to pick a raw GUID; the select still lets them switch
|
||||
// if more types are added later.
|
||||
if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId)
|
||||
})
|
||||
.catch(() => setUserTypes([]))
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setUsername("")
|
||||
setFullName("")
|
||||
setEmail("")
|
||||
setMobileNumber("")
|
||||
setNic("")
|
||||
setRoleId("")
|
||||
setUserTypeId("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!username.trim()) nextErrors.username = "Username is required"
|
||||
if (!fullName.trim()) nextErrors.fullName = "Full name is required"
|
||||
if (!email.trim()) nextErrors.email = "Email is required"
|
||||
if (!roleId) nextErrors.roleId = "Role is required"
|
||||
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await usersApi.create({
|
||||
username: username.trim(),
|
||||
fullName: fullName.trim(),
|
||||
email: email.trim(),
|
||||
mobileNumber: mobileNumber || null,
|
||||
nic: nic || null,
|
||||
roleId: Number(roleId),
|
||||
userTypeId: userTypeId.trim(),
|
||||
})
|
||||
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe) setErrors(fe)
|
||||
toast.error("Could not create user", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Create accounts and assign roles. New users receive their credentials by email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New user</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.username}>
|
||||
<FieldLabel htmlFor="u-username">Username</FieldLabel>
|
||||
<Input id="u-username" value={username} onChange={(e) => setUsername(e.target.value)} aria-invalid={!!errors.username} />
|
||||
<FieldError errors={[errors.username ? { message: errors.username } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.fullName}>
|
||||
<FieldLabel htmlFor="u-fullname">Full name</FieldLabel>
|
||||
<Input id="u-fullname" value={fullName} onChange={(e) => setFullName(e.target.value)} aria-invalid={!!errors.fullName} />
|
||||
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor="u-email">Email</FieldLabel>
|
||||
<Input id="u-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} />
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="u-mobile">Mobile number (optional)</FieldLabel>
|
||||
<Input id="u-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="u-nic">NIC (optional)</FieldLabel>
|
||||
<Input id="u-nic" value={nic} onChange={(e) => setNic(e.target.value)} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.roleId}>
|
||||
<FieldLabel htmlFor="u-role">Role</FieldLabel>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger id="u-role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.userTypeId}>
|
||||
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
||||
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
||||
<SelectTrigger id="u-usertype">
|
||||
<SelectValue placeholder="Select a user type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userTypes.map((t) => (
|
||||
<SelectItem key={t.userTypeId} value={t.userTypeId}>
|
||||
{t.code ?? t.userTypeId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : 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={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "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 && users === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<UsersIcon className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No users yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Username</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Display name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Role</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{u.username}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.displayName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.roleName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{u.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${u.username}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Suspense, useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
@@ -41,6 +41,14 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
@@ -8,83 +8,133 @@ import {
|
||||
Building2,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
Ruler,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
Truck,
|
||||
Users,
|
||||
Warehouse,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
|
||||
const navItems: {
|
||||
title: string
|
||||
code: string
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: { title: string; href: string; icon: LucideIcon }[]
|
||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||
}[] = [
|
||||
{ title: "Dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{
|
||||
title: "Products",
|
||||
code: "products",
|
||||
href: "/dashboard/products",
|
||||
icon: Package,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ 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: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
],
|
||||
},
|
||||
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{ title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
||||
{ title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{ title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true },
|
||||
{ title: "Help", href: "/dashboard/help", icon: HelpCircle },
|
||||
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{
|
||||
title: "Procurement",
|
||||
code: "procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: ClipboardList,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
|
||||
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "Settings",
|
||||
code: "settings",
|
||||
href: "/dashboard/settings",
|
||||
icon: Settings,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
||||
]
|
||||
|
||||
function SidebarContent({
|
||||
items,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onClose,
|
||||
pathname,
|
||||
isMobile,
|
||||
}: {
|
||||
items: typeof navItems
|
||||
collapsed: boolean
|
||||
onCollapse: () => void
|
||||
onClose?: () => void
|
||||
pathname: string
|
||||
isMobile: boolean
|
||||
}) {
|
||||
const iconOnly = !isMobile && collapsed
|
||||
|
||||
// Which parent menus are open. Starts with the parent that owns the active
|
||||
// route auto-expanded; user toggles are preserved across navigation.
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const parent = items.find((i) =>
|
||||
i.children?.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
|
||||
)
|
||||
if (parent) {
|
||||
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
|
||||
}
|
||||
}, [pathname, items])
|
||||
|
||||
const toggleExpand = (code: string) =>
|
||||
setExpanded((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-200",
|
||||
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-300 ease-in-out",
|
||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-10 flex items-center gap-2.5 px-4 py-3",
|
||||
!isMobile && collapsed ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
|
||||
"mb-8 flex shrink-0 items-center gap-2.5 px-4 py-3",
|
||||
iconOnly ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
|
||||
)}
|
||||
>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
||||
@@ -93,7 +143,7 @@ function SidebarContent({
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{(!collapsed || isMobile) && (
|
||||
{!iconOnly && (
|
||||
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -108,79 +158,116 @@ function SidebarContent({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
{/* Nav items — scrolls internally when it overflows, without a visible
|
||||
scrollbar so the rounded panel stays clean. */}
|
||||
<ul className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
|
||||
const hasChildren = !!item.children?.length && !iconOnly
|
||||
const isOpen = !!expanded[item.code]
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
title={!isMobile && collapsed ? item.title : undefined}
|
||||
onClick={onClose}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold transition-colors",
|
||||
!isMobile && collapsed && "justify-center px-0",
|
||||
isActive
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-slate-700 hover:bg-slate-50"
|
||||
"flex items-center rounded-2xl transition-colors",
|
||||
isActive ? "bg-indigo-50" : "hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{(!collapsed || isMobile) && (
|
||||
<>
|
||||
<span className="flex-1">{item.title}</span>
|
||||
{item.chevron && !item.children && !isActive && (
|
||||
<ChevronRight className="size-4 shrink-0 text-slate-300" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href={item.href}
|
||||
title={iconOnly ? item.title : undefined}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
|
||||
iconOnly && "justify-center px-0",
|
||||
isActive ? "text-indigo-600" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{!iconOnly && (
|
||||
<>
|
||||
<span className="flex-1">{item.title}</span>
|
||||
{item.chevron && !hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{item.children && (!collapsed || isMobile) && (
|
||||
<ul className="mt-1 flex flex-col gap-0.5 pl-11">
|
||||
{(() => {
|
||||
// Longest-matching href wins so a shared prefix (e.g. "Item" and
|
||||
// "Category" both live under /dashboard/products) doesn't light up
|
||||
// more than one sub-item at once.
|
||||
const activeChild = [...item.children]
|
||||
.filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
|
||||
.sort((a, b) => b.href.length - a.href.length)[0]
|
||||
return item.children.map((child) => {
|
||||
const childActive = child.href === activeChild?.href
|
||||
return (
|
||||
<li key={child.href}>
|
||||
<Link
|
||||
href={child.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
childActive
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<child.icon
|
||||
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{child.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</ul>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(item.code)}
|
||||
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60",
|
||||
isActive ? "text-indigo-500" : "text-slate-400"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-in-out",
|
||||
isOpen ? "mt-1 grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ul className="flex flex-col gap-0.5 pl-11">
|
||||
{(() => {
|
||||
// Longest-matching href wins so a shared prefix (e.g. "Item" and
|
||||
// "Category" both live under /dashboard/products) doesn't light up
|
||||
// more than one sub-item at once.
|
||||
const activeChild = [...item.children!]
|
||||
.filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
|
||||
.sort((a, b) => b.href.length - a.href.length)[0]
|
||||
return item.children!.map((child) => {
|
||||
const childActive = child.href === activeChild?.href
|
||||
return (
|
||||
<li key={child.href}>
|
||||
<Link
|
||||
href={child.href}
|
||||
onClick={onClose}
|
||||
tabIndex={isOpen ? undefined : -1}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
childActive
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<child.icon
|
||||
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{child.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto flex items-center justify-center pt-6">
|
||||
<div className="flex items-center justify-center pt-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
|
||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
@@ -195,6 +282,19 @@ export function AppSidebar() {
|
||||
const pathname = usePathname() || "/"
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { navCodes, loading } = useAuth()
|
||||
|
||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
@@ -213,6 +313,7 @@ export function AppSidebar() {
|
||||
{/* ── Desktop sidebar ─────────────────────────────── */}
|
||||
<div className="my-3 hidden lg:my-4 lg:flex">
|
||||
<SidebarContent
|
||||
items={visibleItems}
|
||||
collapsed={collapsed}
|
||||
onCollapse={() => setCollapsed((v) => !v)}
|
||||
pathname={pathname}
|
||||
@@ -247,6 +348,7 @@ export function AppSidebar() {
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
items={visibleItems}
|
||||
collapsed={false}
|
||||
onCollapse={() => {}}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
|
||||
interface AuthContextValue {
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
/** True until the first `/auth/me` response lands. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
loading: true,
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => {
|
||||
if (!cancelled) setMe(res)
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [] })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
loading: me === null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
// Checkbox tree for assigning a role's visible sidebar sections. Leaf nav items
|
||||
// (no children) are toggled directly; parent nav items with children are a
|
||||
// "select all children" convenience toggle — the parent's own visibility is
|
||||
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
|
||||
// only the children need to carry the actual grant for those groups.
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { NavItem } from "@/types/rbac"
|
||||
|
||||
interface Props {
|
||||
tree: NavItem[]
|
||||
selectedNavItemIds: Set<number>
|
||||
selectedSubNavItemIds: Set<number>
|
||||
onChange: (navItemIds: Set<number>, subNavItemIds: Set<number>) => void
|
||||
}
|
||||
|
||||
export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavItemIds, onChange }: Props) {
|
||||
function toggleLeaf(navItemId: number) {
|
||||
const next = new Set(selectedNavItemIds)
|
||||
if (next.has(navItemId)) next.delete(navItemId)
|
||||
else next.add(navItemId)
|
||||
onChange(next, selectedSubNavItemIds)
|
||||
}
|
||||
|
||||
function toggleChild(subNavItemId: number) {
|
||||
const next = new Set(selectedSubNavItemIds)
|
||||
if (next.has(subNavItemId)) next.delete(subNavItemId)
|
||||
else next.add(subNavItemId)
|
||||
onChange(selectedNavItemIds, next)
|
||||
}
|
||||
|
||||
function toggleAllChildren(item: NavItem, checked: boolean) {
|
||||
const next = new Set(selectedSubNavItemIds)
|
||||
for (const child of item.children) {
|
||||
if (checked) next.add(child.subNavItemId)
|
||||
else next.delete(child.subNavItemId)
|
||||
}
|
||||
onChange(selectedNavItemIds, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border p-4">
|
||||
{tree.map((item) => {
|
||||
if (item.children.length === 0) {
|
||||
return (
|
||||
<label key={item.navItemId} className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedNavItemIds.has(item.navItemId)}
|
||||
onCheckedChange={() => toggleLeaf(item.navItemId)}
|
||||
/>
|
||||
<Label className="cursor-pointer text-base font-medium">{item.label}</Label>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
|
||||
const allChecked = checkedCount === item.children.length
|
||||
|
||||
return (
|
||||
<div key={item.navItemId} className="flex flex-col gap-1 border-t pt-2 first:border-t-0 first:pt-0">
|
||||
<label className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||
<Checkbox checked={allChecked} onCheckedChange={(checked) => toggleAllChildren(item, !!checked)} />
|
||||
<Label className="cursor-pointer text-base font-semibold">
|
||||
{item.label} {checkedCount > 0 && !allChecked && <span className="text-sm font-normal text-muted-foreground">({checkedCount} of {item.children.length})</span>}
|
||||
</Label>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 pl-9">
|
||||
{item.children.map((child) => (
|
||||
<label key={child.subNavItemId} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedSubNavItemIds.has(child.subNavItemId)}
|
||||
onCheckedChange={() => toggleChild(child.subNavItemId)}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{child.label}</Label>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,40 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
|
||||
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
|
||||
// so their text isn't otherwise available. Rather than pass `items` at all ~60 call sites,
|
||||
// this wrapper walks its own `SelectItem` children and derives that map automatically, so the
|
||||
// trigger shows the selected item's label instead of its value.
|
||||
function collectItems(
|
||||
children: React.ReactNode,
|
||||
acc: { value: unknown; label: React.ReactNode }[]
|
||||
) {
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child)) return
|
||||
if (child.type === SelectItem) {
|
||||
const p = child.props as { value?: unknown; children?: React.ReactNode }
|
||||
acc.push({ value: p.value, label: p.children })
|
||||
return
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode }).children
|
||||
if (nested) collectItems(nested, acc)
|
||||
})
|
||||
}
|
||||
|
||||
function Select<Value, Multiple extends boolean | undefined = false>(
|
||||
props: SelectPrimitive.Root.Props<Value, Multiple>
|
||||
) {
|
||||
const { items, children } = props
|
||||
const derivedItems = React.useMemo(() => {
|
||||
if (items) return items
|
||||
const acc: { value: unknown; label: React.ReactNode }[] = []
|
||||
collectItems(children, acc)
|
||||
return acc.length ? (acc as ReadonlyArray<{ value: Value; label: React.ReactNode }>) : undefined
|
||||
}, [items, children])
|
||||
|
||||
return <SelectPrimitive.Root {...props} items={derivedItems} />
|
||||
}
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
// 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"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
|
||||
export const authApi = {
|
||||
/** Authoritative role + permitted sidebar nav codes for the current session. */
|
||||
me(): Promise<MeResponse> {
|
||||
return apiRequest<MeResponse>("/auth/me")
|
||||
},
|
||||
|
||||
/** 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 })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Read-only sidebar nav tree (ERPCore Controllers/NavController.cs).
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { NavItem } from "@/types/rbac"
|
||||
|
||||
export const navApi = {
|
||||
tree(): Promise<NavItem[]> {
|
||||
return apiRequest<NavItem[]>("/nav")
|
||||
},
|
||||
}
|
||||
@@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams {
|
||||
sort?: string
|
||||
}
|
||||
|
||||
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns
|
||||
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */
|
||||
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
|
||||
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
|
||||
export function isPoEditable(status: PurchaseOrderStatus): boolean {
|
||||
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
|
||||
return status === "Draft"
|
||||
}
|
||||
|
||||
export const purchaseOrdersApi = {
|
||||
@@ -54,6 +54,16 @@ export const purchaseOrdersApi = {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */
|
||||
submit(poId: number): Promise<PurchaseOrder> {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/submit`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Delete a Draft PO. 409 PO_NOT_EDITABLE once submitted. */
|
||||
remove(poId: number): Promise<void> {
|
||||
return apiRequest<void>(`/purchase-orders/${poId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
/** 409 if any receipt exists against the PO. */
|
||||
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Role CRUD + permission assignment (ERPCore Controllers/RolesController.cs).
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
AssignRolePermissionsRequest,
|
||||
CreateRoleRequest,
|
||||
Role,
|
||||
RolePermissions,
|
||||
UpdateRoleRequest,
|
||||
} from "@/types/rbac"
|
||||
|
||||
export interface ListRolesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
}
|
||||
|
||||
export const rolesApi = {
|
||||
list(params: ListRolesParams = {}): Promise<PagedResponse<Role>> {
|
||||
return apiRequest<PagedResponse<Role>>(`/roles${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(roleId: number): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>(`/roles/${roleId}`)
|
||||
},
|
||||
|
||||
create(request: CreateRoleRequest): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>("/roles", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(roleId: number, request: UpdateRoleRequest, ifMatch: string): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>(`/roles/${roleId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
updateStatus(roleId: number, status: EntityStatus): Promise<void> {
|
||||
return apiRequest<void>(`/roles/${roleId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
|
||||
remove(roleId: number): Promise<void> {
|
||||
return apiRequest<void>(`/roles/${roleId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
getPermissions(roleId: number): Promise<RolePermissions> {
|
||||
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`)
|
||||
},
|
||||
|
||||
assignPermissions(roleId: number, request: AssignRolePermissionsRequest): Promise<RolePermissions> {
|
||||
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`, { method: "PUT", body: request })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// User management (ERPCore Controllers/UsersController.cs). Create orchestrates
|
||||
// account creation in both AuthHex and ERPCore's local shadow table server-side.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UserTypeOption } from "@/types/users"
|
||||
|
||||
export interface ListUsersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list(params: ListUsersParams = {}): Promise<PagedResponse<ManagedUser>> {
|
||||
return apiRequest<PagedResponse<ManagedUser>>(`/users${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(userId: number): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>(`/users/${userId}`)
|
||||
},
|
||||
|
||||
create(request: CreateUserRequest): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>("/users", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
updateRole(userId: number, request: UpdateUserRoleRequest): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
userTypes(): Promise<UserTypeOption[]> {
|
||||
return apiRequest<UserTypeOption[]>("/users/user-types")
|
||||
},
|
||||
}
|
||||
@@ -15,18 +15,10 @@ export const emailSchema = z.string().min(1, "Email is required").email("Enter a
|
||||
// Login schema (email + password) for reuse across the app
|
||||
export const loginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: requiredString("Password is required")
|
||||
.refine((val) => val.length >= 8, { message: "Password must be at least 8 characters" })
|
||||
.refine((val) => /[A-Z]/.test(val), {
|
||||
message: "Password must contain at least one uppercase letter",
|
||||
})
|
||||
.refine((val) => /[a-z]/.test(val), {
|
||||
message: "Password must contain at least one lowercase letter",
|
||||
})
|
||||
.refine((val) => /[0-9]/.test(val), { message: "Password must contain at least one number" })
|
||||
.refine((val) => /[!@#$%^&*(),.?":{}|<>\[\]\\/`~;'+=-]/.test(val), {
|
||||
message: "Password must contain at least one special character",
|
||||
}),
|
||||
// Login only checks that a password was typed — complexity rules belong to
|
||||
// signup/reset. Enforcing them here just leaks the policy and blocks users
|
||||
// whose existing password predates it; the server decides what's valid.
|
||||
password: requiredString("Password is required"),
|
||||
})
|
||||
|
||||
export type LoginValues = z.infer<typeof loginSchema>
|
||||
|
||||
@@ -16,6 +16,8 @@ export function validateLine(input: {
|
||||
uomId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
trackingMode: TrackingMode | null
|
||||
batchNo: string
|
||||
serialNumbersText: string
|
||||
@@ -31,6 +33,14 @@ export function validateLine(input: {
|
||||
const unitCost = Number(input.unitCost)
|
||||
if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative"
|
||||
|
||||
const discountPct = Number(input.discountPct)
|
||||
if (input.discountPct !== "" && (Number.isNaN(discountPct) || discountPct < 0 || discountPct > 100))
|
||||
errors.discountPct = "Discount must be 0–100%"
|
||||
|
||||
const vatPct = Number(input.vatPct)
|
||||
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
|
||||
errors.vatPct = "VAT must be 0–100%"
|
||||
|
||||
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
|
||||
errors.batchNo = "Batch number is required for this item"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,16 @@ export interface CreateGrnLineInput {
|
||||
uomId: number
|
||||
binId?: number | null
|
||||
qty: number
|
||||
/**
|
||||
* Gross unit cost. For a PO line it is an optional per-receipt override — 0/omitted uses
|
||||
* the PO price; a value wins and the server records a variance (docs/02-SECURITY C.3,
|
||||
* revised). Required (> 0) for a direct receipt.
|
||||
*/
|
||||
unitCost: number
|
||||
/** Trade discount % (0–100). Reduces inventory cost. */
|
||||
discountPct?: number
|
||||
/** VAT % (0–100). Recoverable — does not affect stock value. */
|
||||
vatPct?: number
|
||||
holdStatus: HoldStatus
|
||||
batch?: BatchInput | null
|
||||
}
|
||||
@@ -49,8 +58,21 @@ export interface GrnLine {
|
||||
uomId: number
|
||||
binId: number | null
|
||||
qty: number
|
||||
/** Gross unit cost received at. */
|
||||
unitCost: number
|
||||
/** PO price snapshot at receipt; null for direct receipts. */
|
||||
poUnitPrice: number | null
|
||||
discountPct: number
|
||||
/** After-discount cost — what the FIFO layer is valued at. */
|
||||
netUnitCost: number
|
||||
vatPct: number
|
||||
vatAmount: number
|
||||
/** qty × netUnitCost (after discount, before VAT). */
|
||||
receivedValue: number
|
||||
/** qty × netUnitCost + vatAmount — payable to vendor. */
|
||||
lineTotal: number
|
||||
/** (unitCost − poUnitPrice) × qty; 0 for direct receipts. */
|
||||
priceVariance: number
|
||||
holdStatus: HoldStatus
|
||||
batchId: number | null
|
||||
}
|
||||
|
||||
@@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest {
|
||||
vendorId: number
|
||||
requisitionId?: number | null
|
||||
lines: CreatePoLineInput[]
|
||||
/** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */
|
||||
saveAsDraft?: boolean
|
||||
}
|
||||
|
||||
/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */
|
||||
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
|
||||
/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */
|
||||
export type UpdatePurchaseOrderRequest = Omit<CreatePurchaseOrderRequest, "saveAsDraft">
|
||||
|
||||
export interface CancelPurchaseOrderRequest {
|
||||
reason?: string | null
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Role / Nav / Permission DTOs (mirrors ERPCore's Dtos/Rbac/*.cs exactly).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export interface Role {
|
||||
roleId: number
|
||||
code: string
|
||||
name: string
|
||||
isSystemRole: boolean
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubNavItem {
|
||||
subNavItemId: number
|
||||
code: string
|
||||
label: string
|
||||
icon: string | null
|
||||
href: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
navItemId: number
|
||||
code: string
|
||||
label: string
|
||||
icon: string | null
|
||||
href: string | null
|
||||
sortOrder: number
|
||||
children: SubNavItem[]
|
||||
}
|
||||
|
||||
export interface RolePermissions {
|
||||
roleId: number
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
}
|
||||
|
||||
export interface AssignRolePermissionsRequest {
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Managed-user DTOs (mirrors ERPCore's Dtos/Users/UserDtos.cs).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export interface ManagedUser {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
status: EntityStatus
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
fullName: string
|
||||
roleId: number
|
||||
userTypeId: string
|
||||
email: string
|
||||
nic?: string | null
|
||||
mobileNumber?: string | null
|
||||
/** Left empty to auto-generate — AuthHex emails it to `email`. */
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
roleId: number
|
||||
}
|
||||
|
||||
/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */
|
||||
export interface UserTypeOption {
|
||||
userTypeId: string
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
+3
-3
@@ -83,9 +83,9 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
|
||||
- [ ] Note in review: **AR-01/AR-02/AR-03** apply to these endpoints
|
||||
|
||||
### C.3 GRN
|
||||
- [ ] `unitCost` **derived from the PO line server-side**; any client-supplied cost is ignored *(decision locked)*
|
||||
- [ ] `receivedValue` computed server-side (qty × PO-line cost), not accepted from client
|
||||
- [ ] Direct GRN (no PO) is the exception where cost is entered → extra scrutiny + review flag + audit (**AR-04**)
|
||||
- [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block.
|
||||
- [ ] **Derived figures stay server-computed** — `netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**.
|
||||
- [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**)
|
||||
- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE`
|
||||
- [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
|
||||
| FR-PROC-02 | Optional **RFQ**: issue to vendors, record quotations for comparison. | S |
|
||||
| FR-PROC-03 | Generate **PO** from PR/RFQ or directly (item, UOM, qty, price, tax, delivery date, warehouse). | M |
|
||||
| FR-PROC-04 | **[Phase 1: auto-approve]** Auto-approve PO on creation (status `Approved`). Config flag `approvalRequired` (default off) gates a future approval workflow (authorization matrix); when on, PO cannot issue until approved. `PendingApproval` state + approval fields retained in schema (no migration to enable). | M |
|
||||
| FR-PROC-05 | **[Phase 1: Option B — edit-while-open]** PO may be **freely edited while open** (not fully received/closed); changes take effect immediately with an audit entry. Versioned amendments deferred; schema must not preclude adding a version field later. | S |
|
||||
| FR-PROC-05 | **[Phase 1: Option B *superseded* 2026-07-20 — draft-lock]** A PO is **editable and deletable only while `Draft`**; **submitting locks it** (Draft → Approved) and no further edit/delete/add-line is allowed — an issued PO is corrected by Cancel-with-reason (blocked once receipts exist) or a reversing document, never edited. Create takes `saveAsDraft` (default `false` → auto-approve, preserving the Requisition→PO / RFQ→PO flows). *Why the reversal:* Option B ("freely edit while open") let an already-issued, vendor-facing PO change silently after the fact; the draft/submit boundary makes "issued to vendor" a real, immutable commitment. Versioned amendments still deferred; schema unchanged (reuses the existing `Draft` enum value). | S |
|
||||
| FR-PROC-06 | PO lifecycle: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled. Phase 1 bypasses PendingApproval via auto-approve. | M |
|
||||
| FR-PROC-07 | Support **partial receipt**; PO stays open until fully received or manually closed. | M |
|
||||
| FR-PROC-08 | Support **Purchase Return** referencing original GRN/PO line; generates outbound movement. | M |
|
||||
@@ -151,7 +151,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
|
||||
| FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S |
|
||||
| FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M |
|
||||
| FR-GRN-05 | Allow receipt into **inspection/quarantine hold** (not issuable) pending QC, before QC module exists. | M |
|
||||
| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at unit cost (PO price + attributable charges; landed cost per §B.1.2.1) and posts an inbound ledger entry. | M |
|
||||
| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at the **after-discount net unit cost** (`unitCost × (1 − discountPct/100)`) and posts an inbound ledger entry. **VAT never enters stock value** — it is recoverable input tax (revised 2026-07-20). PO price is the default unit cost; a per-line override is permitted and recorded as a variance (see 02-SECURITY C.3, revised). | M |
|
||||
| FR-GRN-07 | Record **received value per line** and PO reference for downstream matching. | M |
|
||||
| FR-GRN-08 | Assign received stock to a **bin/location** (putaway). | S |
|
||||
|
||||
@@ -194,7 +194,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
|
||||
| FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M |
|
||||
|
||||
## B.4 Data Model (summary)
|
||||
Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission).
|
||||
Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and RBAC for sidebar visibility (Role, NavItem, SubNavItem, Permission, RolePermission — see C.8; per-endpoint enforcement still deferred).
|
||||
|
||||
## B.5 External Interfaces
|
||||
UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules.
|
||||
@@ -348,13 +348,16 @@ AUDIT_LOG(audit_id PK, user_id FK→USER, entity_type, entity_id, action, change
|
||||
JOURNAL_ENTRY_STUB(journal_id PK, source_doc_type, source_doc_id, debit_account, credit_account, amount)
|
||||
```
|
||||
|
||||
## C.8 Reserved (RBAC — deferred, schema placeholder only)
|
||||
## C.8 RBAC — sidebar-visibility only (implemented 2026-07-18); per-endpoint enforcement still deferred
|
||||
```
|
||||
ROLE(role_id PK, name)
|
||||
PERMISSION(permission_id PK, code)
|
||||
USER_ROLE(user_id FK→USER, role_id FK→ROLE)
|
||||
ROLE(role_id PK, auth_role_id [GUID, unique] → AuthHex Role, code, name, is_system_role, status, created_at, updated_at, row_version) -- local shadow/projection of AuthHex's Role, same pattern as USER
|
||||
NAV_ITEM(nav_item_id PK, code, label, icon, href, sort_order, status) -- top-level sidebar entry; seeded to match the frontend
|
||||
SUB_NAV_ITEM(sub_nav_item_id PK, nav_item_id FK→NAV_ITEM, code, label, icon, href, sort_order, status)
|
||||
PERMISSION(permission_id PK, code, nav_item_id FK→NAV_ITEM [nullable], sub_nav_item_id FK→SUB_NAV_ITEM [nullable]) -- exactly one of the two FKs is set
|
||||
ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
|
||||
USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (see C.7)
|
||||
```
|
||||
Note: `USER_ROLE` from the original placeholder sketch was dropped — a user has at most one role (`USER.role_id`), matching AuthHex's own `User.RoleId` being a single scalar FK, not a many-to-many.
|
||||
|
||||
## C.9 Modeling notes (load-bearing)
|
||||
- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy.
|
||||
@@ -366,7 +369,7 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
|
||||
- **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost.
|
||||
- **FEFO ≠ FIFO.** FIFO governs *costing*; FEFO governs *physical picking* of perishables via `BATCH.expiry_date`.
|
||||
- **External IdP + shadow user.** Authentication is delegated to **AuthHex** (RS256, issuer `AuthHex`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders.
|
||||
- **Reserved RBAC.** Role/Permission/UserRole/RolePermission exist for schema-completeness only; only `USER` is live (audit stamp). AuthHex's `RoleCode`/`UserTypeCode` claims drive the door gate today; per-endpoint RBAC is future work.
|
||||
- **RBAC — sidebar visibility, not endpoint enforcement (2026-07-18).** `Role`/`NavItem`/`SubNavItem`/`Permission`/`RolePermission` are now live tables backing Role CRUD (`RolesController`) and a permission-assignment UI. AuthHex remains the source of truth for `Role` identity (Guid PK, referenced by its JWT `RoleId`/`RoleCode` claims); ERPCore's `Role` is a **local shadow synced on write** — `RolesController` calls AuthHex's new `/api/role` functions first, then mirrors the result into the local int-keyed row (`auth_role_id` maps the two), exactly like `USER`/`auth_user_id`. `GET /api/v1/auth/me` resolves the caller's `RoleCode` claim to its local `Role`, joins `RolePermission`, and returns the permitted `NavItem`/`SubNavItem` codes for the frontend to filter its sidebar by. **This is deliberately UI-only**: no endpoint in this API (including the new Role/User/Nav ones) gained an authorization check from this work — AR-01 in `02-SECURITY.md` is unchanged, and per-endpoint RBAC remains future work (Part D there).
|
||||
- **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required.
|
||||
|
||||
## C.10 Entity → implementation mapping
|
||||
|
||||
@@ -111,6 +111,57 @@ Request/response field shapes match AuthHex's own payloads one-for-one (project-
|
||||
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
|
||||
`erp_rt` cookie rather than the request body.
|
||||
|
||||
### 2.0.1 RBAC — Roles, sidebar nav, Users (added 2026-07-18; sidebar-visibility only, see docs/10 C.8)
|
||||
|
||||
`GET /api/v1/auth/me` — the frontend's authoritative source for the current session's role and permitted sidebar sections
|
||||
(replaces the previous client-only `roleId` cached in localStorage). No payload.
|
||||
**200 OK**
|
||||
```json
|
||||
{ "roleCode": "ADMIN", "roleName": "Administrator", "navCodes": ["dashboard", "products", "products.item", "settings.roles", "..."] }
|
||||
```
|
||||
|
||||
`GET /api/v1/nav` — read-only sidebar tree (`NavItem` + nested `SubNavItem`), seeded to mirror the frontend's hardcoded
|
||||
sidebar (`components/Layouts/AppSidebar.tsx`); used to render the Role permission-assignment checkbox UI. Not admin-editable
|
||||
in this phase.
|
||||
|
||||
**Roles** (`RolesController`) — `Role` is a **local shadow of AuthHex's Role** (same pattern as `USER`/`auth_user_id`,
|
||||
docs/10 C.9): every write below forwards to AuthHex's new `/api/role` functions first, then mirrors the result locally.
|
||||
| Route | Notes |
|
||||
|---|---|
|
||||
| `GET /roles` | Paged list; `q`, `status` filters. |
|
||||
| `GET /roles/{roleId}` | `ETag` header for `If-Match` on update. |
|
||||
| `POST /roles` | `{ code, name }` → `201`, forwards to AuthHex `createRole`. |
|
||||
| `PUT /roles/{roleId}` | Requires `If-Match`; forwards to AuthHex `updateRole`. |
|
||||
| `PATCH /roles/{roleId}/status` | `{ status }` → `204`. |
|
||||
| `DELETE /roles/{roleId}` | Forwards to AuthHex `deleteRole`; `409 ROLE_IN_USE` if any user still holds it. |
|
||||
| `GET /roles/{roleId}/permissions` | `{ roleId, navItemIds, subNavItemIds }`. |
|
||||
| `PUT /roles/{roleId}/permissions` | Replaces the role's full permission set from `{ navItemIds, subNavItemIds }` — purely local, no AuthHex call. |
|
||||
|
||||
> **`code` is server-accepted but frontend-derived, never hand-typed (2026-07-18).** The Roles UI computes `code` from
|
||||
> `name` (uppercased, non-alphanumeric → `_`) and submits it read-only; the field stays free-form here for API callers,
|
||||
> but no UI lets an operator type or edit it directly, on create or later. The Create Role dialog also now includes the
|
||||
> permission checkbox tree, so `POST /roles` and `PUT /roles/{roleId}/permissions` fire as one user action.
|
||||
>
|
||||
> **Bug fixed (2026-07-18): `POST /roles`/`PUT /roles/{roleId}` 500ing via `AUTH_UPSTREAM_ERROR`.** `IsSystemRole` being
|
||||
> omitted serialized as JSON `null`, and AuthHex's `createRole`/`updateRole` called `JsonElement.GetBoolean()` on it
|
||||
> unconditionally when the key was present — which throws on `null` (unlike `GetString()`, which tolerates it). Fixed on
|
||||
> both sides: AuthHex now checks `ValueKind != JsonValueKind.Null` before reading `isSystemRole`, and ERPCore's
|
||||
> `AuthHexClient` now serializes with `JsonIgnoreCondition.WhenWritingNull` so unset nullable fields are omitted from the
|
||||
> payload entirely rather than sent as explicit nulls — closing this class of bug for any other nullable field sent to AuthHex.
|
||||
|
||||
**Users** (`UsersController`) — manages the local shadow `User` table and orchestrates account creation in AuthHex.
|
||||
| Route | Notes |
|
||||
|---|---|
|
||||
| `GET /users` | Paged list, joined with `Role`. |
|
||||
| `GET /users/{userId}` | Single record. |
|
||||
| `POST /users` | Creates the account in **both** backends: calls AuthHex's `registerUser` (which persists the password and emails it to the given `email`), then immediately mirrors the local shadow `User` row (rather than waiting for next-login JIT provisioning). Body: `{ username, fullName, roleId, userTypeId, email, nic?, mobileNumber?, password? }` (`password` empty ⇒ AuthHex auto-generates one). |
|
||||
| `PUT /users/{userId}/role` | `{ roleId }` — local role reassignment only; status/lock changes reuse the existing `/auth/status` and `/auth/lock` proxy endpoints. |
|
||||
| `GET /users/user-types` | Added 2026-07-18. Proxies AuthHex's new `listUserTypes` — `[{ userTypeId, code, description }]`. Populates the Create User form's UserType select so operators pick from a real list instead of typing an AuthHex GUID by hand; defaults to the sole existing type when only one exists. |
|
||||
|
||||
> **Known gap, not fixed (flagged 2026-07-18):** AuthHex's `loginUser` resolves `identifier` against `Email`/`MobileNumber`/`Nic`
|
||||
> only — **not** `Username` (`UserManageRepository.GetUserByIdentifierAndType`). A user created via `POST /users` can log in
|
||||
> with their email but not their username. Out of scope for this change; revisit if/when asked.
|
||||
|
||||
### 2.1 Items
|
||||
> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9).
|
||||
|
||||
@@ -406,14 +457,15 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of
|
||||
`GET /rfqs/{rfqId}/comparison` → vendor-by-line price matrix.
|
||||
|
||||
### 3.3 Purchase Orders
|
||||
> **Phase 1:** `approvalRequired` defaults `false` → PO **auto-approved on creation**. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO **freely editable while open** (Option B, FR-PROC-05).
|
||||
> **Phase 1:** `approvalRequired` defaults `false`. Create takes **`saveAsDraft`** (default `false` → **auto-approved on creation**; `true` → `Draft`). Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). **A PO is editable/deletable only while `Draft`; submitting locks it** (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded).
|
||||
|
||||
#### `POST /purchase-orders`
|
||||
```json
|
||||
{ "vendorId": 5, "requisitionId": 210,
|
||||
{ "vendorId": 5, "requisitionId": 210, "saveAsDraft": false,
|
||||
"lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 },
|
||||
{ "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] }
|
||||
```
|
||||
`saveAsDraft` optional (default `false`). When `true` the response `status` is `Draft`.
|
||||
**201 Created** — `Location: /api/v1/purchase-orders/342`
|
||||
```json
|
||||
{ "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210,
|
||||
@@ -426,7 +478,11 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of
|
||||
`GET /purchase-orders?status=Approved&vendorId=5` → list envelope of PO summaries.
|
||||
|
||||
#### `PUT /purchase-orders/{poId}`
|
||||
Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` if closed.
|
||||
Edit a **Draft only**; requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` once submitted (any non-Draft status).
|
||||
|
||||
#### `POST /purchase-orders/{poId}/submit` → **200 OK** — `Draft → Approved`. `409 PO_NOT_EDITABLE` if not Draft.
|
||||
|
||||
#### `DELETE /purchase-orders/{poId}` → **204 No Content** — permitted **only while Draft**; `409 PO_NOT_EDITABLE` once submitted.
|
||||
|
||||
#### `POST /purchase-orders/{poId}/approve` → **200 OK** (no-op in Phase 1; transitions PendingApproval→Approved when enabled).
|
||||
|
||||
@@ -473,15 +529,24 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm
|
||||
```json
|
||||
{ "poId": 342, "warehouseId": 1,
|
||||
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000,
|
||||
"unitCost": 12.50, "holdStatus": "OnHold",
|
||||
"unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold",
|
||||
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }
|
||||
```
|
||||
**201 Created** — status `Draft`
|
||||
`discountPct`/`vatPct` optional (default 0, range 0–100). `unitCost` on a **PO line** is an optional
|
||||
override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised).
|
||||
On a direct receipt `unitCost` is required.
|
||||
**201 Created** — status `Draft`. All derived figures are **server-computed**:
|
||||
`netUnitCost = unitCost × (1 − discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount,
|
||||
**before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`,
|
||||
`lineTotal = receivedValue + vatAmount`, `priceVariance = (unitCost − poUnitPrice) × qty`.
|
||||
```json
|
||||
{ "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1,
|
||||
"status": "Draft", "createdBy": 17,
|
||||
"lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45,
|
||||
"qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] }
|
||||
"qty": 5000, "unitCost": 12.50, "poUnitPrice": 12.50, "discountPct": 10.0,
|
||||
"netUnitCost": 11.25, "vatPct": 18.0, "vatAmount": 10125.00,
|
||||
"receivedValue": 56250.00, "lineTotal": 66375.00, "priceVariance": 0.00,
|
||||
"holdStatus": "OnHold", "batchId": 410 } ] }
|
||||
```
|
||||
`422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance.
|
||||
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ Principles:
|
||||
|
||||
## 2. User flows
|
||||
|
||||
The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role (roles are conceptual; RBAC is not enforced in Phase 1).
|
||||
The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role. Roles now drive real sidebar visibility (`GET /auth/me`'s `navCodes`, see docs/10 C.8/docs/11 §2.0.1, admin screens at `/dashboard/settings/roles` and `/dashboard/settings/users`) but **per-endpoint RBAC is still not enforced** — this remains a UI-level filter only.
|
||||
|
||||
> **Roles screen (2026-07-18):** `code` is never typed by an operator — it's derived client-side from `name` (uppercased, non-alphanumeric → `_`) and shown read-only, on both create and edit. The Create Role dialog also includes the permission checkbox tree (`components/auth/RolePermissionTree.tsx`, fed by `GET /nav`), so creating a role and assigning its sidebar permissions is one Save action; the detail page (`/dashboard/settings/roles/[id]`) remains for later edits. The Create User dialog's "User type" is a `<Select>` populated from `GET /users/user-types` (proxying AuthHex's `listUserTypes`), defaulting to the sole existing type — operators never type an AuthHex UserType GUID by hand.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
|
||||
Reference in New Issue
Block a user