Branch crud #52

Merged
ImanThiyanga merged 2 commits from branch-crud into Dev 2026-09-08 12:43:48 +05:30
113 changed files with 41598 additions and 174 deletions
Showing only changes of commit f664961fce - Show all commits
@@ -0,0 +1,44 @@
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Branch configuration endpoints — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
/// flip this flag until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/branch-config")]
public sealed class BranchConfigController : ApiControllerBase
{
private readonly IBranchConfigService _config;
public BranchConfigController(IBranchConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BranchConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchConfigDto>> Update(
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -15,6 +15,6 @@ public sealed class DashboardController : ApiControllerBase
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
@@ -22,8 +22,8 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -25,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
@@ -1,6 +1,7 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -15,11 +16,13 @@ public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
{
_users = users;
_links = links;
_currentUser = currentUser;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
@@ -53,7 +56,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
+5 -2
View File
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// <see cref="Employee.BranchId"/>, optionally referenced by <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
/// this deployment uses branches at all. When <see cref="BranchingEnabled"/> is off, the
/// Employee/Warehouse create forms stop asking for a branch and every new record is
/// silently assigned to the deployment's one default branch instead — schema-wise
/// <c>Employee.BranchId</c>/<c>Warehouse.BranchId</c> stay <c>NOT NULL</c> either way, only
/// the UI/UX requirement toggles. See <see cref="Services.BranchConfigService.ResolveBranchIdAsync"/>.
/// </summary>
public class BranchConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool BranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+1 -1
View File
@@ -50,7 +50,7 @@ public class Employee
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; }
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public ICollection<Bin> Bins { get; set; } = new List<Bin>();
}
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// <see cref="BranchingEnabled"/> is <see cref="bool"/>? deliberately: <c>[Required]</c> on a
/// non-nullable bool is a no-op, so an omitted flag would otherwise silently bind to
/// <c>false</c>. Nullable makes the requirement actually bind — an omitted flag is a 400.
/// </para>
/// </summary>
public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
}
+4 -4
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
int EmploymentTypeId, string? EmploymentTypeName, int BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
int DepartmentId, int DesignationId, int EmploymentTypeId, int BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
@@ -45,7 +45,7 @@ public sealed class CreateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
+4 -2
View File
@@ -7,20 +7,22 @@ namespace ERPCore.Dtos.Hrm;
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
int BranchId, string Code, string Name, string? Address, EntityStatus Status,
int BranchId, string Code, string Name, string? Address, string? PhoneNumber, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed record DepartmentDto(
+10 -2
View File
@@ -2,5 +2,13 @@ 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);
/// the stale, client-only `roleId` cached in localStorage). <see cref="BranchId"/>/
/// <see cref="BranchName"/> come from the caller's linked <c>Employee</c> record, when
/// one exists — null for a login with no Employee link. <see cref="IsAdmin"/> gates
/// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
+16
View File
@@ -30,6 +30,22 @@ public sealed class CreateUserRequest
/// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
/// </summary>
public int? LinkEmployeeId { get; set; }
/// <summary>
/// Required, together with <see cref="DepartmentId"/>/<see cref="DesignationId"/>/
/// <see cref="EmploymentTypeId"/>/<see cref="WorkShiftId"/>/<see cref="BranchId"/>, whenever
/// the assigned Role is not Admin and <see cref="LinkEmployeeId"/> is not supplied: creates
/// the linked Employee record whose BranchId is what actually scopes this account to one
/// branch (see BranchAccessService.EnsureBranchVisibleAsync).
/// </summary>
public string? EmployeeCode { get; set; }
public int? BranchId { get; set; }
public int? DepartmentId { get; set; }
public int? DesignationId { get; set; }
public int? EmploymentTypeId { get; set; }
public int? WorkShiftId { get; set; }
/// <summary>Defaults to today when omitted.</summary>
public DateTime? HireDate { get; set; }
}
public sealed class UpdateUserRoleRequest
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). <see cref="BranchId"/> is
/// the anchor `docs/BRANCH_ISOLATION_PLAN.md` uses to branch-scope everything that carries
/// a `warehouseId` (Sales, GRN, stock, ...) without a column on every one of those entities.</summary>
public sealed record WarehouseDto(int WarehouseId, string Code, string Name, int BranchId, string? BranchName);
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
@@ -12,6 +14,7 @@ public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public int BranchId { get; set; }
}
public sealed class CreateBinRequest
@@ -31,4 +31,8 @@ public sealed class CurrentUser : ICurrentUser
}
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
}
@@ -19,4 +19,14 @@ public interface ICurrentUser
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
string? RoleCode { get; }
/// <summary>
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary>
bool IsAdmin { get; }
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton branch-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors ProductConfig).
/// </summary>
public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchConfig>
{
public void Configure(EntityTypeBuilder<BranchConfig> builder)
{
builder.ToTable("branch_config", t =>
t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -16,6 +16,7 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.PhoneNumber).HasMaxLength(30);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
@@ -66,7 +66,8 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
);
}
}
@@ -31,6 +31,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
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 = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
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 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
@@ -15,5 +15,9 @@ public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
builder.HasOne(w => w.Branch).WithMany()
.HasForeignKey(w => w.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(w => w.BranchId);
}
}
@@ -45,6 +45,7 @@ public static class DataSeeder
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -110,6 +111,23 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton branch-config row exists. New deployments start with branching
/// on — today's mandatory-branch behaviour — so nothing changes for anyone until an
/// admin deliberately switches it off from Settings.
/// </summary>
private static async Task<bool> SeedBranchConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.BranchConfig.AnyAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)) return false;
db.BranchConfig.Add(new BranchConfig
{
ConfigId = BranchConfig.SingletonId,
BranchingEnabled = true
});
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -38,6 +38,8 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchPhoneNumberAndCodeAutogen : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PhoneNumber",
table: "hr_branches",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhoneNumber",
table: "hr_branches");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSettingsBranchesNavItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 29, "settings.branches", "/dashboard/settings/branches", null, "Branches", 9, 0 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 40, "NAV:settings.branches", null, 29 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 29);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MakeEmployeeBranchRequired : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Branch becomes a required field on Employee going forward. Any pre-existing
// employee left with no branch is backfilled to a "Main Branch" placeholder
// (created only if it doesn't already exist) so the NOT NULL below never fails
// on live data.
migrationBuilder.Sql(@"
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
SELECT 'BR-000', 'Main Branch', 'Active', now()
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
AND EXISTS (SELECT 1 FROM hr_employees WHERE ""BranchId"" IS NULL);
UPDATE hr_employees
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
WHERE ""BranchId"" IS NULL;
");
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddWarehouseBranch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "warehouses",
type: "integer",
nullable: true);
// Backfill any pre-existing warehouse onto the same "Main Branch" placeholder
// used by the earlier MakeEmployeeBranchRequired migration (created only if it
// doesn't already exist) — keeps a single de-facto default rather than minting
// a second one — before the column is locked to NOT NULL below.
migrationBuilder.Sql(@"
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
SELECT 'BR-000', 'Main Branch', 'Active', now()
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
AND EXISTS (SELECT 1 FROM warehouses WHERE ""BranchId"" IS NULL);
UPDATE warehouses
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
WHERE ""BranchId"" IS NULL;
");
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "warehouses",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_warehouses_BranchId",
table: "warehouses",
column: "BranchId");
migrationBuilder.AddForeignKey(
name: "FK_warehouses_hr_branches_BranchId",
table: "warehouses",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_warehouses_hr_branches_BranchId",
table: "warehouses");
migrationBuilder.DropIndex(
name: "IX_warehouses_BranchId",
table: "warehouses");
migrationBuilder.DropColumn(
name: "BranchId",
table: "warehouses");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "branch_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
BranchingEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_branch_config", x => x.ConfigId);
table.CheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_branch_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_branch_config_UpdatedBy",
table: "branch_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "branch_config");
}
}
}
@@ -297,6 +297,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PhoneNumber")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -323,6 +327,38 @@ namespace ERPCore.Migrations
b.ToTable("hr_branches", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("BranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("branch_config", null, t =>
{
t.HasCheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
{
b.Property<int>("BrandId")
@@ -890,7 +926,7 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("BranchId")
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("City")
@@ -2943,6 +2979,12 @@ namespace ERPCore.Migrations
PermissionId = 39,
Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28
},
new
{
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
});
});
@@ -5394,6 +5436,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 29,
Code = "settings.branches",
Href = "/dashboard/settings/branches",
Label = "Branches",
NavItemId = 9,
SortOrder = 0,
Status = "Active"
},
new
{
SubNavItemId = 7,
Code = "settings.roles",
@@ -5906,6 +5958,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
@@ -5918,6 +5973,8 @@ namespace ERPCore.Migrations
b.HasKey("WarehouseId");
b.HasIndex("BranchId");
b.HasIndex("Code")
.IsUnique();
@@ -6053,6 +6110,16 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
@@ -6188,7 +6255,8 @@ namespace ERPCore.Migrations
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict);
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Department", "Department")
.WithMany()
@@ -7680,6 +7748,17 @@ namespace ERPCore.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
@@ -95,7 +95,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
@@ -105,6 +105,7 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
@@ -0,0 +1,36 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class BranchAccessService : IBranchAccessService
{
private readonly ICurrentUser _currentUser;
private readonly IRepository<Employee> _employees;
private readonly IBranchConfigService _branchConfig;
public BranchAccessService(ICurrentUser currentUser, IRepository<Employee> employees, IBranchConfigService branchConfig)
{
_currentUser = currentUser;
_employees = employees;
_branchConfig = branchConfig;
}
public async Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default)
{
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return;
if (!await _branchConfig.IsBranchingEnabledAsync(ct)) return;
var ownBranchId = await _employees.Query().AsNoTracking()
.Where(e => e.UserId == _currentUser.AuditUserId)
.Select(e => (int?)e.BranchId)
.FirstOrDefaultAsync(ct);
if (ownBranchId is null || ownBranchId != branchId)
throw new NotFoundException("The requested record was not found.");
}
}
@@ -0,0 +1,103 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Branch configuration service over the singleton row seeded by DataSeeder. Also owns
/// resolving the effective branch id for an Employee/Warehouse write — the one place that
/// knows how to fall back to a default branch when branching is switched off.
/// </summary>
public sealed class BranchConfigService : IBranchConfigService
{
private const string DefaultBranchCode = "BR-000";
private const string DefaultBranchName = "Main Branch";
private readonly IRepository<BranchConfig> _config;
private readonly IRepository<Branch> _branches;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public BranchConfigService(IRepository<BranchConfig> config, IRepository<Branch> branches, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_branches = branches;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)
?? throw new NotFoundException("Branch configuration has not been seeded.");
return new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<BranchConfigDto>> UpdateAsync(
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(BranchConfig.SingletonId, ct)
?? throw new NotFoundException("Branch configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
}
public async Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
// Seeded at startup (DataSeeder) — missing only for a database built some other way,
// in which case the safer default is "on" (today's mandatory behaviour), not silently off.
return config?.BranchingEnabled ?? true;
}
public async Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
// Branching is off — ignore whatever was posted and use the deployment's one default
// branch, creating it on first use (same placeholder MakeEmployeeBranchRequired/
// AddWarehouseBranch backfill onto for pre-existing rows).
var existingId = await _branches.Query().AsNoTracking()
.Where(b => b.Code == DefaultBranchCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var branch = new Branch { Code = DefaultBranchCode, Name = DefaultBranchName, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _branches.AddAsync(branch, ct);
await _uow.SaveChangesAsync(ct);
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+2 -1
View File
@@ -97,7 +97,7 @@ public sealed class CountService : ICountService
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
@@ -108,6 +108,7 @@ public sealed class CountService : ICountService
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
+21 -11
View File
@@ -40,18 +40,19 @@ public sealed class DashboardService : IDashboardService
_reorder = reorder;
}
public async Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default)
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default)
{
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await _layers.Query().AsNoTracking()
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking()
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
var layers = _layers.Query().AsNoTracking();
if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record.
var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking()
var stockValuationByWarehouseRaw = await layers
.GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total)
@@ -59,17 +60,26 @@ public sealed class DashboardService : IDashboardService
var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList();
var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct);
var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
// (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column,
// not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var pendingCounts = await _counts.Query().AsNoTracking()
.CountAsync(c => c.Status == CountStatus.Counted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct))
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
return new DashboardStatsDto(
+4 -1
View File
@@ -73,7 +73,7 @@ public sealed class GrnService : IGrnService
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
@@ -86,6 +86,9 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
+27 -7
View File
@@ -36,7 +36,7 @@ public sealed class BranchService : IBranchService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(b => b.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt))
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total);
@@ -50,15 +50,12 @@ public sealed class BranchService : IBranchService
public async Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _branches.Query().AnyAsync(b => b.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A branch with code '{code}' already exists.");
var branch = new Branch
{
Code = code,
Code = await GenerateCodeAsync(ct),
Name = request.Name.Trim(),
Address = request.Address?.Trim(),
PhoneNumber = request.PhoneNumber?.Trim(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -69,6 +66,28 @@ public sealed class BranchService : IBranchService
return new ETagged<BranchDto>(Map(branch), branch.RowVersion);
}
private const string CodePrefix = "BR-";
/// <summary>
/// Next sequential branch code ("BR-001", "BR-002", ...) — codes are never user-entered.
/// Parses the numeric suffix of existing codes rather than trusting a row count, since a
/// deactivated-not-deleted branch never frees its slot and counts would drift.
/// </summary>
private async Task<string> GenerateCodeAsync(CancellationToken ct)
{
var existingCodes = await _branches.Query().AsNoTracking()
.Where(b => b.Code.StartsWith(CodePrefix))
.Select(b => b.Code)
.ToListAsync(ct);
var nextNumber = existingCodes
.Select(c => int.TryParse(c.AsSpan(CodePrefix.Length), out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max() + 1;
return $"{CodePrefix}{nextNumber:D3}";
}
public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
@@ -79,6 +98,7 @@ public sealed class BranchService : IBranchService
branch.Name = request.Name.Trim();
branch.Address = request.Address?.Trim();
branch.PhoneNumber = request.PhoneNumber?.Trim();
branch.UpdatedAt = DateTime.UtcNow;
try
@@ -103,5 +123,5 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct);
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt);
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
}
@@ -21,14 +21,19 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow;
}
@@ -66,7 +71,10 @@ public sealed class EmployeeService : IEmployeeService
public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct);
return employee is null ? null : new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
if (employee is null) return null;
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
return new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
}
public async Task<ETagged<EmployeeDetailDto>> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default)
@@ -85,6 +93,8 @@ public sealed class EmployeeService : IEmployeeService
linkedUserId = user.UserId;
}
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var employee = new Employee
{
EmployeeCode = code,
@@ -107,7 +117,7 @@ public sealed class EmployeeService : IEmployeeService
DepartmentId = request.DepartmentId,
DesignationId = request.DesignationId,
EmploymentTypeId = request.EmploymentTypeId,
BranchId = request.BranchId,
BranchId = branchId,
WorkShiftId = request.WorkShiftId,
ReportingManagerId = request.ReportingManagerId,
EpfNumber = request.EpfNumber?.Trim(),
@@ -131,6 +141,8 @@ public sealed class EmployeeService : IEmployeeService
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
if (employee.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412);
@@ -154,7 +166,7 @@ public sealed class EmployeeService : IEmployeeService
employee.DepartmentId = request.DepartmentId;
employee.DesignationId = request.DesignationId;
employee.EmploymentTypeId = request.EmploymentTypeId;
employee.BranchId = request.BranchId;
employee.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
employee.WorkShiftId = request.WorkShiftId;
employee.ReportingManagerId = request.ReportingManagerId;
employee.EpfNumber = request.EpfNumber?.Trim();
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
@@ -0,0 +1,19 @@
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Server-side branch-scoping guard for detail/update endpoints (docs/BRANCH_ISOLATION_PLAN.md
/// "What this doesn't solve by itself" — the Phase-2 enforcement layer the plan flags as
/// missing). List endpoints filter by an explicit <c>branchId</c> query param the frontend
/// supplies; this instead protects a single record fetched/edited by id, where a non-admin
/// could otherwise guess or type another branch's id directly.
/// </summary>
public interface IBranchAccessService
{
/// <summary>
/// No-op when branching is disabled (docs/BRANCH_ISOLATION_PLAN.md's toggle) or the caller
/// is Admin. Otherwise throws <see cref="ERPCore.System.Errors.NotFoundException"/> — not a
/// 403 — when <paramref name="branchId"/> isn't the caller's own branch, so a record in
/// another branch reads as "doesn't exist" rather than confirming it exists elsewhere.
/// </summary>
Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default);
}
@@ -0,0 +1,23 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Branch configuration business logic. Singleton.</summary>
public interface IBranchConfigService
{
Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<BranchConfigDto>> UpdateAsync(
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>True when this deployment requires a branch to be picked explicitly.</summary>
Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>
/// The branch id an Employee/Warehouse write should actually use: <paramref name="requestedBranchId"/>
/// as-is when branching is enabled (rejecting an unset one), or the deployment's single
/// default branch when branching is disabled — creating that default branch on first use.
/// </summary>
Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
@@ -5,5 +5,5 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService
{
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -7,6 +7,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService
{
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
Task<RequisitionDto> CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -10,10 +10,10 @@ public interface IStockService
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
@@ -12,7 +12,7 @@ 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> CreateAsync(CreateUserRequest request, int actorUserId, 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>
+2 -1
View File
@@ -26,10 +26,11 @@ public sealed class ReorderService : IReorderService
_requisitions = requisitions;
}
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
{
var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(r => r.Warehouse!.BranchId == branchId);
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>();
+26 -4
View File
@@ -4,6 +4,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -15,20 +16,28 @@ namespace ERPCore.Services;
public sealed class RoleService : IRoleService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IAuthHexClient authHex, IUnitOfWork uow)
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_currentUser = currentUser;
_authHex = authHex;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -184,17 +193,30 @@ public sealed class RoleService : IRoleService
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
// when the logged-in account has no linked Employee (docs/12-BACKEND-HRM.md A.5).
var employee = _currentUser.IsAuthenticated
? await _employees.Query().AsNoTracking()
.Include(e => e.Branch)
.FirstOrDefaultAsync(e => e.UserId == _currentUser.AuditUserId, ct)
: null;
var branchId = employee?.BranchId;
var branchName = employee?.Branch?.Name;
var branchingEnabled = await _branchConfig.IsBranchingEnabledAsync(ct);
if (string.IsNullOrWhiteSpace(roleCode))
return new MeResponseDto(null, null, Array.Empty<string>());
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled);
// AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an
// identity code differing only by case must not lock the user out of the nav.
var normalized = roleCode.Trim();
var isAdmin = string.Equals(normalized, AdminRoleCode, StringComparison.OrdinalIgnoreCase);
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
if (role is null)
return new MeResponseDto(roleCode, null, Array.Empty<string>());
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled);
var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
@@ -210,7 +232,7 @@ public sealed class RoleService : IRoleService
.Distinct()
.ToList();
return new MeResponseDto(role.Code, role.Name, navCodes);
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled);
}
private static RoleDto Map(Role r) => new(
@@ -49,7 +49,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -60,6 +60,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
+20 -3
View File
@@ -14,17 +14,30 @@ public sealed class StockService : IStockService
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Warehouse> _warehouses;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
_warehouses = warehouses;
}
/// <summary>
/// Ids of every warehouse in <paramref name="branchId"/>'s branch, or null when no branch
/// filter applies. Stock entities carry only a raw <c>WarehouseId</c> (no navigation
/// property, by design — see <c>StockLedger</c>'s doc comment), so branch-scoping goes
/// through this id list rather than a LINQ join on a nav property.
/// </summary>
private async Task<List<int>?> ResolveBranchWarehouseIdsAsync(int? branchId, CancellationToken ct)
=> branchId is null
? null
: await _warehouses.Query().AsNoTracking().Where(w => w.BranchId == branchId).Select(w => w.WarehouseId).ToListAsync(ct);
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
@@ -58,11 +71,13 @@ public sealed class StockService : IStockService
/// warehouse simply does not appear.
/// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
{
var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
if (branchWarehouseIds is not null) layers = layers.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId })
@@ -116,12 +131,14 @@ public sealed class StockService : IStockService
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
if (branchWarehouseIds is not null) q = q.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically
+4 -1
View File
@@ -50,7 +50,7 @@ public sealed class TransferService : ITransferService
}
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default)
{
var q = _transfers.Query().AsNoTracking();
@@ -62,6 +62,9 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side
// rather than only the branch that happens to hold the source warehouse.
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
@@ -16,20 +17,24 @@ public sealed class UserManagementService : IUserManagementService
{
private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex,
IEmployeeUserLinkService links, IUnitOfWork uow)
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_employees = employees;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_employeeService = employeeService;
_uow = uow;
}
@@ -57,11 +62,30 @@ public sealed class UserManagementService : IUserManagementService
return user is null ? null : Map(user);
}
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
// Non-admin roles are branch-scoped (BranchAccessService.EnsureBranchVisibleAsync
// resolves the caller's branch via their linked Employee) — either link an existing,
// already branch-scoped Employee, or supply enough to create a new one.
var isAdminRole = string.Equals(role.Code, "Admin", StringComparison.OrdinalIgnoreCase);
string? employeeCode = null;
if (!isAdminRole && request.LinkEmployeeId is null)
{
if (string.IsNullOrWhiteSpace(request.EmployeeCode) || request.DepartmentId is null
|| request.DesignationId is null || request.EmploymentTypeId is null || request.WorkShiftId is null)
{
throw new DomainException(ErrorCodes.Validation,
"Non-admin users must be assigned to a branch: employeeCode, branchId, departmentId, designationId, employmentTypeId and workShiftId are required.", 422);
}
employeeCode = request.EmployeeCode.Trim();
if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == employeeCode.ToLower(), ct))
throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{employeeCode}' already exists.", 400);
}
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.");
@@ -103,7 +127,27 @@ public sealed class UserManagementService : IUserManagementService
// Explicit, human-confirmed link to an existing Staff record (never automatic).
if (request.LinkEmployeeId is not null)
{
await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct);
}
else if (!isAdminRole)
{
await _employeeService.CreateAsync(new CreateEmployeeRequest
{
EmployeeCode = employeeCode!,
FullName = request.FullName.Trim(),
Email = request.Email.Trim(),
PersonalMobile = request.MobileNumber,
Nic = request.Nic,
HireDate = request.HireDate ?? DateTime.UtcNow.Date,
DepartmentId = request.DepartmentId!.Value,
DesignationId = request.DesignationId!.Value,
EmploymentTypeId = request.EmploymentTypeId!.Value,
BranchId = request.BranchId ?? 0,
WorkShiftId = request.WorkShiftId!.Value,
LinkUserId = user.UserId
}, actorUserId, ct);
}
user.Role = role;
return Map(user);
+16 -7
View File
@@ -13,18 +13,24 @@ public sealed class WarehouseService : IWarehouseService
{
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public WarehouseService(IRepository<Warehouse> warehouses, IRepository<Bin> bins, IUnitOfWork uow)
public WarehouseService(
IRepository<Warehouse> warehouses, IRepository<Bin> bins, IRepository<Branch> branches,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_warehouses = warehouses;
_bins = bins;
_branches = branches;
_branchConfig = branchConfig;
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _warehouses.Query().AsNoTracking();
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -34,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name))
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch != null ? w.Branch.Name : null))
.ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
@@ -42,9 +48,9 @@ public sealed class WarehouseService : IWarehouseService
public async Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default)
{
var w = await _warehouses.Query().AsNoTracking()
var w = await _warehouses.Query().AsNoTracking().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch?.Name);
}
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
@@ -53,11 +59,14 @@ public sealed class WarehouseService : IWarehouseService
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim() };
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim(), BranchId = branchId };
await _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name);
var branchName = await _branches.Query().AsNoTracking()
.Where(b => b.BranchId == warehouse.BranchId).Select(b => b.Name).FirstOrDefaultAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name, warehouse.BranchId, branchName);
}
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)
@@ -6,10 +6,12 @@ import { useParams } from "next/navigation"
import { ArrowLeft, BookText } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
@@ -32,10 +34,15 @@ export default function ChequeBookDetailPage() {
const [book, setBook] = useState<ChequeBook | null>(null)
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => {
let cancelled = false
chequeBooksApi
@@ -94,7 +101,7 @@ export default function ChequeBookDetailPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">#{book.branchId}</p>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Range</p>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { validateChequeBookForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
@@ -19,9 +22,11 @@ import { toast } from "@/components/ui/toast"
export default function NewChequeBookPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchId, setBranchId] = useState("")
const [bankAccountId, setBankAccountId] = useState("")
@@ -44,6 +49,21 @@ export default function NewChequeBookPage() {
.catch((err) => setBankAccountsError(errorMessage(err)))
}, [])
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
// Pinned to the header's branch the same way as Employee/Warehouse/GRN/Sale: an Admin on a
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
// still needs *some* branch id regardless of our toggle.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
const showBranchPicker = !lockedBranchId
useEffect(() => {
if (lockedBranchId) setBranchId(String(lockedBranchId))
}, [lockedBranchId])
async function handleSubmit() {
const nextErrors = validateChequeBookForm({
branchId,
@@ -118,15 +138,29 @@ export default function NewChequeBookPage() {
</Field>
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel>
<Input
id="cb-branch"
type="number"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
placeholder="1"
aria-invalid={!!errors.branchId}
/>
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select<string> value={branchId} onValueChange={(v) => setBranchId(v ?? "")}>
<SelectTrigger id="cb-branch" className="w-full text-base" aria-invalid={!!errors.branchId}>
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="cb-branch"
value={branches.find((b) => b.branchId === lockedBranchId)?.name ?? `Branch #${lockedBranchId}`}
readOnly
disabled
className="text-muted-foreground"
/>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -27,15 +30,24 @@ const STATUS_BADGE: Record<ChequeBookStatus, string> = {
export default function ChequeBooksPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => {
let cancelled = false
Promise.all([
chequeBooksApi.list(status === "All" ? undefined : { status }),
chequeBooksApi.list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
])
.then(([result, banks]) => {
@@ -51,9 +63,10 @@ export default function ChequeBooksPage() {
}
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
}, [status, activeBranchId, branchingEnabled])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
return (
<div className="flex flex-col gap-6">
@@ -132,7 +145,7 @@ export default function ChequeBooksPage() {
<TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo}
</TableCell>
@@ -1,27 +1,34 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { ReceivedFromType } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const NONE = "__none__"
export default function NewReceivedChequePage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [companyId, setCompanyId] = useState("")
const [branchId, setBranchId] = useState("")
const [branches, setBranches] = useState<Branch[]>([])
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
const [receivedFromId, setReceivedFromId] = useState("")
const [receivedFromName, setReceivedFromName] = useState("")
@@ -39,6 +46,19 @@ export default function NewReceivedChequePage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
// Same pinning as the New Cheque Book form: locked whenever the header (or the caller's own
// branch, for non-admins) resolves to one; a free pick only on "All Branches" or the toggle off.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
const showBranchPicker = !lockedBranchId
useEffect(() => {
if (lockedBranchId) setBranchId(String(lockedBranchId))
}, [lockedBranchId])
async function handleSubmit() {
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
setErrors(nextErrors)
@@ -100,8 +120,30 @@ export default function NewReceivedChequePage() {
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel>
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} />
<FieldLabel htmlFor="rc-branch">Branch (optional)</FieldLabel>
{showBranchPicker ? (
<Select<string> value={branchId || NONE} onValueChange={(v) => setBranchId(v && v !== NONE ? v : "")}>
<SelectTrigger id="rc-branch" className="w-full text-base">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE} label="No branch" className="text-base">No branch</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="rc-branch"
value={branches.find((b) => b.branchId === lockedBranchId)?.name ?? `Branch #${lockedBranchId}`}
readOnly
disabled
className="text-muted-foreground"
/>
)}
</Field>
<Field>
@@ -1,15 +1,18 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -28,16 +31,25 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
}
export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => {
let cancelled = false
receivedChequesApi
.list(status === "All" ? undefined : { status })
.list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
if (!cancelled) setCheques(res.items)
})
@@ -47,7 +59,9 @@ export default function ReceivedChequesPage() {
return () => {
cancelled = true
}
}, [status])
}, [status, activeBranchId, branchingEnabled])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
@@ -114,6 +128,7 @@ export default function ReceivedChequesPage() {
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
@@ -132,6 +147,9 @@ export default function ReceivedChequesPage() {
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
@@ -5,10 +5,11 @@ import { useParams } from "next/navigation"
import { Link2, Plus, Upload } from "lucide-react"
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import {
Branch,
Department,
Designation,
EmployeeBankDetail,
@@ -23,6 +24,7 @@ import {
WorkShift,
} from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
@@ -43,6 +45,7 @@ function money(n: number): string {
export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>()
const employeeId = Number(params.id)
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
const [tab, setTab] = useState<Tab>("Overview")
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
@@ -53,6 +56,7 @@ export default function EmployeeDetailPage() {
const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [saving, setSaving] = useState(false)
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
@@ -92,7 +96,8 @@ export default function EmployeeDetailPage() {
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([]))
}, [])
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
useEffect(() => {
if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err)))
@@ -271,6 +276,15 @@ export default function EmployeeDetailPage() {
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<SelectTrigger disabled={!isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
)}
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div>
@@ -5,12 +5,13 @@ import Link from "next/link"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { Branch, CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
@@ -31,10 +32,19 @@ const emptyForm: CreateEmployeeRequest = {
departmentId: 0,
designationId: 0,
employmentTypeId: 0,
branchId: 0,
workShiftId: 0,
}
export default function EmployeesPage() {
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
// The branch to pin the new hire to without asking: an Admin's currently-selected
// header branch, or a non-admin's own branch. Only when an Admin is on "All Branches"
// (no single branch implied) do they need the dropdown to pick one explicitly.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -45,6 +55,7 @@ export default function EmployeesPage() {
const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
@@ -57,18 +68,22 @@ export default function EmployeesPage() {
function load() {
employeesApi
.list({ page, pageSize: PAGE_SIZE, q: q || undefined })
.list({ page, pageSize: PAGE_SIZE, q: q || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, q])
useEffect(load, [page, q, activeBranchId, branchingEnabled])
// Switching branch changes what "page 1" even means — stay on a page past the
// new filtered result set otherwise.
useEffect(() => setPage(1), [activeBranchId])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
}, [])
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return }
@@ -98,6 +113,7 @@ export default function EmployeesPage() {
if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -126,7 +142,14 @@ export default function EmployeesPage() {
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
</div>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm() }}>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) resetForm()
else if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
}}
>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
<DialogContent className="sm:max-w-lg">
<DialogHeader className="items-center text-center">
@@ -173,7 +196,7 @@ export default function EmployeesPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
<Select value={String(form.departmentId)} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
<SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
@@ -181,7 +204,7 @@ export default function EmployeesPage() {
</Field>
<Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="e-desig">Designation</FieldLabel>
<Select value={form.designationId ? String(form.designationId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
<Select value={String(form.designationId)} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
<SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
@@ -189,7 +212,7 @@ export default function EmployeesPage() {
</Field>
<Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
<Select value={form.employmentTypeId ? String(form.employmentTypeId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
<Select value={String(form.employmentTypeId)} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
<SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
@@ -197,12 +220,28 @@ export default function EmployeesPage() {
</Field>
<Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
<Select value={form.workShiftId ? String(form.workShiftId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
<Select value={String(form.workShiftId)} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
<SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select value={String(form.branchId)} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
) : (
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{lockedBranchName ?? "No branch assigned — contact an admin"}
</div>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
</div>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
@@ -1,16 +1,7 @@
"use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Branch } from "@/types/hrm"
import { BranchMasterPage } from "@/components/settings/BranchMasterPage"
export default function BranchesPage() {
return (
<CodeNameMasterPage<Branch>
title="Branches"
description="Company locations/branches — used for multi-branch employee and payroll scoping."
idOf={(b) => b.branchId}
api={branchesHrmApi}
/>
)
return <BranchMasterPage />
}
+8 -4
View File
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"
import { DashboardStats } from "@/types/dashboard"
import { LedgerEntry } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { StatCard } from "@/components/ui/stat-card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
@@ -42,6 +43,8 @@ const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72]
const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58]
export default function DashboardPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
@@ -53,11 +56,12 @@ export default function DashboardPage() {
const from = new Date()
from.setDate(from.getDate() - (TREND_DAYS - 1))
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([
dashboardApi.stats(),
stockApi.ledger({ page: 1, pageSize: 5 }),
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }),
dashboardApi.stats({ branchId }),
stockApi.ledger({ page: 1, pageSize: 5, branchId }),
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }),
warehousesApi.list(),
])
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
@@ -74,7 +78,7 @@ export default function DashboardPage() {
return () => {
cancelled = true
}
}, [])
}, [activeBranchId, branchingEnabled])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
@@ -9,6 +9,7 @@ import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
@@ -18,7 +19,9 @@ import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -174,10 +177,13 @@ function emptyLine(): DraftLine {
export default function NewGrnPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [mode, setMode] = useState<Mode>("po")
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
@@ -246,6 +252,31 @@ export default function NewGrnPage() {
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
// Admin-only branch picker, narrowing which warehouses show up below — non-admins never
// see this control, their warehouse choices are narrowed to their own branch directly.
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
// the dropdown only appears when the header itself is on "All Branches", so there's no
// branch to pin to and the Admin must pick one explicitly for this GRN.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? (warehouses ?? []) : (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId]
)
// Switching branch can strip the currently-picked warehouse out of the visible set —
// clear it rather than silently submitting a warehouse from the previous branch.
useEffect(() => {
setWarehouseId((prev) => (prev !== null && !visibleWarehouses.some((w) => w.warehouseId === prev) ? null : prev))
}, [visibleWarehouses])
function switchMode(next: Mode) {
setMode(next)
setPoId(null)
@@ -571,6 +602,30 @@ export default function NewGrnPage() {
</div>
)}
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
@@ -578,7 +633,7 @@ export default function NewGrnPage() {
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
@@ -9,6 +9,7 @@ import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -29,6 +30,8 @@ type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 5
export default function GrnListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -48,7 +51,7 @@ export default function GrnListPage() {
// Any filter change starts back at page 1.
useEffect(() => {
setPage(1)
}, [query, status])
}, [query, status, activeBranchId])
function load() {
setError(null)
@@ -58,6 +61,7 @@ export default function GrnListPage() {
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
setGrns(res.items)
@@ -66,7 +70,7 @@ export default function GrnListPage() {
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, query, status])
useEffect(load, [page, query, status, activeBranchId, branchingEnabled])
const hasFilters = query.length > 0 || status !== "All"
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Plus, Trash2 } from "lucide-react"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -22,8 +23,10 @@ import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles"
import { toast } from "@/components/ui/toast"
@@ -90,11 +93,14 @@ function NewSalesInvoiceContent() {
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0)
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
@@ -157,6 +163,32 @@ function NewSalesInvoiceContent() {
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
}
// Admin-only branch picker, narrowing which warehouses show up below — non-admins never
// see this control, their warehouse choices are narrowed to their own branch directly.
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
// the dropdown only appears when the header itself is on "All Branches", so there's no
// branch to pin to and the Admin must pick one explicitly for this sale.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId]
)
// Switching branch can strip the currently-picked warehouse out of the visible set —
// clear it (and every line's warehouse) rather than silently submitting a mismatched one.
useEffect(() => {
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
}
@@ -422,6 +454,30 @@ function NewSalesInvoiceContent() {
</Select>
</div>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select value={branchFilterId ? String(branchFilterId) : ""} onValueChange={(v) => setBranchFilterId(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
@@ -429,7 +485,7 @@ function NewSalesInvoiceContent() {
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
@@ -6,6 +6,7 @@ import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter,
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -28,6 +29,8 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
]
export default function SalesInvoicesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -44,18 +47,23 @@ export default function SalesInvoicesPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
useEffect(() => setPage(1), [query, status, activeBranchId])
useEffect(() => {
setError(null)
salesApi
.listInvoices({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
.listInvoices({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status])
}, [page, status, activeBranchId, branchingEnabled])
async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId)
@@ -0,0 +1,108 @@
"use client"
import { useEffect, useState } from "react"
import { MapPin } from "lucide-react"
import { branchConfigApi } from "@/lib/api/branch-config"
import { errorMessage } from "@/lib/error-map"
import { BranchConfig } from "@/types/hrm"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Branch Configuration — the singleton toggle for whether this deployment uses branches
* at all. Off hides every branch picker/badge across the app (Employee, Warehouse, the
* header switcher, Sales filtering); existing records keep whatever branch they already
* have, and new ones are silently assigned to the deployment's one default branch.
*/
export default function BranchConfigurationPage() {
const [config, setConfig] = useState<BranchConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
branchConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function toggle(next: boolean) {
if (!config) return
setSaving(true)
try {
const res = await branchConfigApi.update({ branchingEnabled: next }, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Branch Configuration</h1>
<p className="text-base text-muted-foreground">
Switch multi-branch mode on or off for this deployment.
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-40 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
<div className="flex items-center gap-2">
<MapPin className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Branches</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.branchingEnabled}
onCheckedChange={toggle}
disabled={saving}
aria-label="Require branches"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Require branches</span>
<span className="text-sm text-muted-foreground">
On: Employee and Warehouse records must pick a branch, and Admins can switch between
branches from the header. Off: branch pickers disappear everywhere and every record
is kept on the deployment&apos;s one default branch behind the scenes.
</span>
</div>
</div>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,7 @@
"use client"
import { BranchMasterPage } from "@/components/settings/BranchMasterPage"
export default function BranchesPage() {
return <BranchMasterPage />
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, LayoutTemplate, SlidersHorizontal } from "lucide-react"
import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -8,6 +8,12 @@ const cards = [
icon: SlidersHorizontal,
description: "Configure product and master-data options",
},
{
title: "Branch Configuration",
href: "/dashboard/settings/branch-configuration",
icon: MapPin,
description: "Turn multi-branch mode on or off for this deployment",
},
{
title: "Invoice Template",
href: "/dashboard/settings/invoice-template",
@@ -1,7 +1,13 @@
import Link from "next/link"
import { BellRing, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
import { BellRing, MapPin, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
const cards = [
{
title: "Branches",
href: "/dashboard/settings/branches",
icon: MapPin,
description: "Manage company branches/locations",
},
{
title: "Roles",
href: "/dashboard/settings/roles",
@@ -7,13 +7,15 @@ import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { usersApi } from "@/lib/api/users"
import { employeeCrossLinkApi } from "@/lib/api/employees"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Role } from "@/types/rbac"
import { EmployeeMatch } from "@/types/hrm"
import { Branch, Department, Designation, EmployeeMatch, EmploymentType, WorkShift } from "@/types/hrm"
import { ManagedUser, UserTypeOption } from "@/types/users"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
@@ -34,6 +36,13 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
export default function UsersPage() {
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
// Same "don't ask if it's already implied" heuristic as the Employees form: an Admin's
// currently-selected header branch, or a non-admin's own branch.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const [users, setUsers] = useState<ManagedUser[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [roles, setRoles] = useState<Role[]>([])
@@ -52,6 +61,25 @@ export default function UsersPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Non-admin roles must be scoped to one branch (see UserManagementService.CreateAsync) —
// these fields build the Employee record that carries it.
const selectedRole = roles.find((r) => String(r.roleId) === roleId)
const requiresBranchScope = !!roleId && selectedRole?.code?.toUpperCase() !== "ADMIN"
const [employeeCode, setEmployeeCode] = useState("")
const [hireDate, setHireDate] = useState(() => new Date().toISOString().slice(0, 10))
const [departmentId, setDepartmentId] = useState(0)
const [designationId, setDesignationId] = useState(0)
const [employmentTypeId, setEmploymentTypeId] = useState(0)
const [workShiftId, setWorkShiftId] = useState(0)
const [branchId, setBranchId] = useState(0)
const [departments, setDepartments] = useState<Department[]>([])
const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
// Advisory cross-link suggestion: does a Staff record already exist with this email?
const [staffMatch, setStaffMatch] = useState<EmployeeMatch | null>(null)
const [checkingEmail, setCheckingEmail] = useState(false)
@@ -98,6 +126,21 @@ export default function UsersPage() {
.catch(() => setUserTypes([]))
}, [])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
// Once a non-admin role is picked, default the branch to whatever's already implied
// (an Admin's selected header branch, or a non-admin creator's own branch) instead of
// making them pick it again.
useEffect(() => {
if (requiresBranchScope && lockedBranchId && !branchId) setBranchId(lockedBranchId)
}, [requiresBranchScope, lockedBranchId, branchId])
function resetForm() {
setUsername("")
setFullName("")
@@ -106,6 +149,13 @@ export default function UsersPage() {
setNic("")
setRoleId("")
setUserTypeId("")
setEmployeeCode("")
setHireDate(new Date().toISOString().slice(0, 10))
setDepartmentId(0)
setDesignationId(0)
setEmploymentTypeId(0)
setWorkShiftId(0)
setBranchId(0)
setErrors({})
setStaffMatch(null)
}
@@ -117,6 +167,17 @@ export default function UsersPage() {
if (!email.trim()) nextErrors.email = "Email is required"
if (!roleId) nextErrors.roleId = "Role is required"
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
// A matched existing Staff record already carries a branch — only ask for a fresh
// one to be created when there's no such link.
if (requiresBranchScope && !staffMatch) {
if (!employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
if (!hireDate) nextErrors.hireDate = "Hire date is required"
if (!departmentId) nextErrors.departmentId = "Department is required"
if (!designationId) nextErrors.designationId = "Designation is required"
if (!employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -131,6 +192,17 @@ export default function UsersPage() {
roleId: Number(roleId),
userTypeId: userTypeId.trim(),
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
...(requiresBranchScope && !staffMatch
? {
employeeCode: employeeCode.trim(),
hireDate,
departmentId,
designationId,
employmentTypeId,
workShiftId,
branchId: branchId || null,
}
: {}),
})
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
setOpen(false)
@@ -164,7 +236,7 @@ export default function UsersPage() {
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader className="items-center text-center">
<DialogTitle>New user</DialogTitle>
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
@@ -225,6 +297,73 @@ export default function UsersPage() {
</Select>
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
</Field>
{requiresBranchScope && !staffMatch && (
<>
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
This role isn&apos;t Admin — it must be scoped to one branch. These details create the linked staff record.
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.employeeCode}>
<FieldLabel htmlFor="u-empcode">Employee code</FieldLabel>
<Input id="u-empcode" value={employeeCode} onChange={(e) => setEmployeeCode(e.target.value)} aria-invalid={!!errors.employeeCode} />
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
</Field>
<Field data-invalid={!!errors.hireDate}>
<FieldLabel htmlFor="u-hire">Hire date</FieldLabel>
<Input id="u-hire" type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} aria-invalid={!!errors.hireDate} />
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="u-dept">Department</FieldLabel>
<Select value={departmentId ? String(departmentId) : undefined} onValueChange={(v) => setDepartmentId(v ? Number(v) : 0)}>
<SelectTrigger id="u-dept" aria-invalid={!!errors.departmentId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
</Field>
<Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="u-desig">Designation</FieldLabel>
<Select value={designationId ? String(designationId) : undefined} onValueChange={(v) => setDesignationId(v ? Number(v) : 0)}>
<SelectTrigger id="u-desig" aria-invalid={!!errors.designationId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
</Field>
<Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="u-etype">Employment type</FieldLabel>
<Select value={employmentTypeId ? String(employmentTypeId) : undefined} onValueChange={(v) => setEmploymentTypeId(v ? Number(v) : 0)}>
<SelectTrigger id="u-etype" aria-invalid={!!errors.employmentTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
</Field>
<Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="u-shift">Work shift</FieldLabel>
<Select value={workShiftId ? String(workShiftId) : undefined} onValueChange={(v) => setWorkShiftId(v ? Number(v) : 0)}>
<SelectTrigger id="u-shift" aria-invalid={!!errors.workShiftId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="u-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select value={branchId ? String(branchId) : undefined} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
<SelectTrigger id="u-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
) : (
<div id="u-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{lockedBranchName ?? "No branch assigned — contact an admin"}
</div>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
</div>
</>
)}
<Field data-invalid={!!errors.userTypeId}>
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
@@ -12,26 +12,33 @@ import { cn } from "@/lib/utils"
import { StockAdjustmentSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
export default function AdjustmentsListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockAdjustmentsApi.list({ pageSize: 50 }), warehousesApi.list(), reasonCodesApi.list("Adjustment")])
Promise.all([
stockAdjustmentsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
warehousesApi.list(),
reasonCodesApi.list("Adjustment"),
])
.then(([a, wh, rc]) => {
setAdjustments(a.items)
setWarehouses(wh.items)
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
})
.catch((err) => setError(errorMessage(err)))
}, [])
}, [activeBranchId, branchingEnabled])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
@@ -11,24 +11,30 @@ import { cn } from "@/lib/utils"
import { StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges"
export default function CountsListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockCountsApi.list({ pageSize: 50 }), warehousesApi.list()])
Promise.all([
stockCountsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
warehousesApi.list(),
])
.then(([c, wh]) => {
setCounts(c.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
}, [activeBranchId, branchingEnabled])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
@@ -11,12 +11,15 @@ import { errorMessage } from "@/lib/error-map"
import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function StockEnquiryPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<OnHand[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -43,6 +46,7 @@ export default function StockEnquiryPage() {
const term = search.trim().toLowerCase()
return rows
.filter((r) => warehouseId === "All" || r.warehouseId === warehouseId)
.filter((r) => !branchingEnabled || activeBranchId === null || warehousesById.get(r.warehouseId)?.branchId === activeBranchId)
.filter((r) => {
if (!term) return true
const item = itemsById.get(r.itemId)
@@ -50,7 +54,7 @@ export default function StockEnquiryPage() {
return haystack.includes(term)
})
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
}, [rows, search, warehouseId, itemsById])
}, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled])
const loading = rows === null || items === null || warehouses === null
@@ -12,6 +12,7 @@ import { LedgerEntry } from "@/types/stock"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -23,6 +24,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
const PAGE_SIZE = 10
export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [entries, setEntries] = useState<LedgerEntry[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -46,7 +49,7 @@ export default function StockLedgerPage() {
useEffect(() => {
setPage(1)
}, [itemId, warehouseId, from, to])
}, [itemId, warehouseId, from, to, activeBranchId])
useEffect(() => {
let cancelled = false
@@ -54,6 +57,7 @@ export default function StockLedgerPage() {
.ledger({
itemId: itemId === "All" ? undefined : itemId,
warehouseId: warehouseId === "All" ? undefined : warehouseId,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
from: from || undefined,
to: to || undefined,
page,
@@ -70,7 +74,7 @@ export default function StockLedgerPage() {
return () => {
cancelled = true
}
}, [itemId, warehouseId, from, to, page])
}, [itemId, warehouseId, from, to, page, activeBranchId, branchingEnabled])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -10,12 +10,15 @@ import { errorMessage } from "@/lib/error-map"
import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function ReorderAlertsPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -24,14 +27,18 @@ export default function ReorderAlertsPage() {
const [requested, setRequested] = useState<Set<string>>(new Set())
useEffect(() => {
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
Promise.all([
stockApi.reorderAlerts(undefined, { branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
itemsApi.list({ pageSize: 200 }),
warehousesApi.list(),
])
.then(([a, it, wh]) => {
setAlerts(a.items)
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
}, [activeBranchId, branchingEnabled])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -11,24 +11,30 @@ import { cn } from "@/lib/utils"
import { StockTransferSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { TransferStatusBadge } from "@/components/stock/status-badges"
export default function TransfersListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockTransfersApi.list({ pageSize: 50 }), warehousesApi.list()])
Promise.all([
stockTransfersApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
warehousesApi.list(),
])
.then(([t, wh]) => {
setTransfers(t.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
}, [activeBranchId, branchingEnabled])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/error-map"
import { Valuation } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -19,6 +20,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
function ValuationContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { activeBranchId, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -50,6 +52,10 @@ function ValuationContent() {
}, [itemId, warehouseId])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
const visibleWarehouses = useMemo(
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
[warehouses, activeBranchId, branchingEnabled]
)
function setParam(key: "itemId" | "warehouseId", value: number) {
const params = new URLSearchParams(searchParams.toString())
@@ -98,7 +104,7 @@ function ValuationContent() {
<SelectValue placeholder="Select a warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
import { ReasonCode } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
@@ -21,6 +22,8 @@ import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function WastagePage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [records, setRecords] = useState<WastageRecord[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -44,11 +47,12 @@ export default function WastagePage() {
wastageApi
.list({
warehouseId: warehouseId === "All" ? undefined : warehouseId,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
})
.then(setRecords)
.catch((err) => setError(errorMessage(err)))
}, [warehouseId, reasonCodeId])
}, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -90,7 +90,10 @@ export default function WarehouseDetailPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
<p className="text-base text-muted-foreground">
{warehouse.name}
{warehouse.branchName && ` · ${warehouse.branchName}`} · Bin/location structure (FR-WH-01, FR-MD-07)
</p>
</div>
</div>
@@ -5,9 +5,12 @@ import Link from "next/link"
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { Bin, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -19,6 +22,7 @@ import {
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"
@@ -42,12 +46,16 @@ function generateWarehouseCode(name: string, existingCodes: string[]): string {
}
export default function WarehousesPage() {
const { branchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [branchId, setBranchId] = useState(0)
const [branches, setBranches] = useState<Branch[]>([])
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
@@ -65,6 +73,9 @@ export default function WarehousesPage() {
}
useEffect(load, [])
useEffect(() => {
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
function binCount(warehouseId: number) {
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
@@ -73,15 +84,17 @@ export default function WarehousesPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required"
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code: generatedCode, name })
const warehouse = await warehousesApi.create({ code: generatedCode, name, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
setOpen(false)
setName("")
setBranchId(0)
setErrors({})
load()
} catch (err) {
@@ -129,6 +142,16 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="wh-branch">Branch</FieldLabel>
<Select value={String(branchId)} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
<SelectTrigger id="wh-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -167,6 +190,7 @@ export default function WarehousesPage() {
<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">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
</TableRow>
</TableHeader>
@@ -179,6 +203,7 @@ export default function WarehousesPage() {
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
<TableCell className="px-3 py-3.5">{w.branchName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
</TableRow>
))}
@@ -32,6 +32,7 @@ import {
LineChart,
ListTree,
Lock,
MapPin,
Menu,
Package,
PackageCheck,
@@ -212,6 +213,7 @@ const navItems: {
icon: Settings,
chevron: true,
children: [
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ 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/settings/configuration", icon: SlidersHorizontal },
@@ -3,12 +3,15 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
import { ArrowLeft, Bell, Building2, LogOut, Settings, User } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { AuthUser } from "@/types/auth"
import { Branch } from "@/types/hrm"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { ThemeToggle } from "@/components/theme-toggle"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@@ -20,6 +23,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
/** Avatar initials from a display name: "ERP Admin" -> "EA", "erpadmin" -> "ER". */
function initials(name: string): string {
@@ -166,9 +170,27 @@ export function Header() {
const router = useRouter()
const title = titleFromPath(pathname)
const showBackButton = pathname !== "/dashboard"
const { isAdmin, branchName, activeBranchId, activeBranchName, setActiveBranch, branchingEnabled } = useAuth()
const branchLabel = isAdmin ? (activeBranchName ?? "All Branches") : branchName
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
const [branches, setBranches] = useState<Branch[]>([])
useEffect(() => {
if (!isAdmin || !branchingEnabled) return
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
const ALL_BRANCHES = "__all__"
function handleBranchChange(value: string | null) {
if (!value || value === ALL_BRANCHES) {
setActiveBranch(null, null)
return
}
const branch = branches.find((b) => String(b.branchId) === value)
setActiveBranch(branch?.branchId ?? null, branch?.name ?? null)
}
// Read after mount so the first client render matches the server render.
const [user, setUser] = useState<AuthUser | null>(null)
@@ -213,6 +235,36 @@ export function Header() {
</div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
{branchingEnabled && (isAdmin ? (
<Select value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES} onValueChange={handleBranchChange}>
<SelectTrigger
className="hidden h-8! shrink-0 gap-1.5 rounded-full border-transparent bg-muted px-2.5 text-xs font-medium text-muted-foreground hover:bg-muted/70 sm:flex"
aria-label="Switch branch"
>
<Building2 className="size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
branchLabel && (
<Badge
variant="outline"
className="hidden shrink-0 items-center gap-1.5 border-transparent bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground sm:flex"
>
<Building2 className="size-3.5" />
{branchLabel}
</Badge>
)
))}
<ThemeToggle />
<Popover>
@@ -298,6 +350,12 @@ export function Header() {
{user ? displayName(user) : "Signed in"}
</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
{branchLabel && (
<p className="mt-1 flex items-center gap-1.5 text-sm font-normal text-muted-foreground">
<Building2 className="size-3.5" />
{branchLabel}
</p>
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -9,10 +9,53 @@ import { createContext, useContext, useEffect, useState } from "react"
import { authApi } from "@/lib/api/auth"
import { MeResponse } from "@/types/rbac"
interface ActiveBranch {
id: number | null
name: string | null
}
/** Admin's last-picked header branch, kept per-browser (docs/BRANCH_ISOLATION_PLAN.md
* §Frontend impact: "a current branch selector... persisted per session"). Purely a
* display/filter convenience — never trusted server-side. */
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
function readStoredActiveBranch(): ActiveBranch | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as ActiveBranch
} catch {
window.localStorage.removeItem(ACTIVE_BRANCH_KEY)
return null
}
}
interface AuthContextValue {
roleCode: string | null
roleName: string | null
navCodes: string[]
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
isAdmin: boolean
/** The caller's own branch, via their linked Employee record — null if unlinked. */
branchId: number | null
branchName: string | null
/**
* The branch currently being "worked in": for non-admins this always mirrors their own
* branch; for an Admin it's whichever branch they last picked in the header (null = "All
* Branches"), persisted per-browser. Screens that want to filter by "the branch the user
* is currently viewing" should read this, not `branchId`.
*/
activeBranchId: number | null
activeBranchName: string | null
/** Admin-only — switches the header's active branch. A no-op for any other role. */
setActiveBranch: (id: number | null, name: string | null) => void
/**
* The deployment-wide toggle (Settings → Configuration → Branch Configuration). False
* means this deployment doesn't use branches at all — every branch picker/badge in the
* app should be hidden entirely, not merely restricted by role.
*/
branchingEnabled: boolean
/** True until the first `/auth/me` response lands. */
loading: boolean
}
@@ -21,11 +64,19 @@ const AuthContext = createContext<AuthContextValue>({
roleCode: null,
roleName: null,
navCodes: [],
isAdmin: false,
branchId: null,
branchName: null,
activeBranchId: null,
activeBranchName: null,
setActiveBranch: () => {},
branchingEnabled: true,
loading: true,
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<MeResponse | null>(null)
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
useEffect(() => {
let cancelled = false
@@ -37,19 +88,39 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.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: [] })
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true })
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (me === null) return
// Non-admins are always pinned to their own branch — never their own past picks.
setActiveBranchState(me.isAdmin ? (readStoredActiveBranch() ?? { id: null, name: null }) : { id: me.branchId, name: me.branchName })
}, [me])
function setActiveBranch(id: number | null, name: string | null) {
if (!me?.isAdmin) return
const next = { id, name }
setActiveBranchState(next)
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
}
return (
<AuthContext.Provider
value={{
roleCode: me?.roleCode ?? null,
roleName: me?.roleName ?? null,
navCodes: me?.navCodes ?? [],
isAdmin: me?.isAdmin ?? false,
branchId: me?.branchId ?? null,
branchName: me?.branchName ?? null,
activeBranchId: activeBranch?.id ?? null,
activeBranchName: activeBranch?.name ?? null,
setActiveBranch,
branchingEnabled: me?.branchingEnabled ?? true,
loading: me === null,
}}
>
@@ -0,0 +1,191 @@
"use client"
// Dedicated Branch list/create/deactivate screen — Branch diverges from the plain
// "code + name" HRM masters (CodeNameMasterPage): its code is server-generated
// (never entered), and it carries Address + Phone Number too. Shared by both
// entry points (Settings → Branches and HRM → Settings → Branches) since Branch
// is a cross-cutting concept now, not HRM-specific.
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Branch } from "@/types/hrm"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Button } 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"
export function BranchMasterPage() {
const PAGE_SIZE = 10
const [items, setItems] = useState<Branch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [address, setAddress] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
branchesHrmApi
.list({ page, pageSize: PAGE_SIZE })
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page])
function resetForm() {
setName("")
setAddress("")
setPhoneNumber("")
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await branchesHrmApi.create({
name: name.trim(),
address: address.trim() || null,
phoneNumber: phoneNumber.trim() || null,
})
toast.success("Branch created")
setOpen(false)
resetForm()
load()
} catch (err) {
toast.error("Could not create branch", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function toggleStatus(item: Branch) {
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
try {
await branchesHrmApi.updateStatus(item.branchId, next)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Branches</h1>
<p className="text-base text-muted-foreground">Company branches/locations — used for multi-branch scoping.</p>
</div>
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) resetForm() }}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New Branch</DialogTitle>
<DialogDescription>Code is generated automatically. Deactivate later — branches are never deleted.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="branch-name">Name</FieldLabel>
<Input id="branch-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="branch-address">Address</FieldLabel>
<Input id="branch-address" value={address} onChange={(e) => setAddress(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="branch-phone">Phone Number</FieldLabel>
<Input id="branch-phone" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" 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 && items === null && (
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
)}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<p className="text-base text-muted-foreground">No branches yet.</p>
</div>
)}
{!error && items !== null && items.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">Address</TableHead>
<TableHead className="h-12 px-3 text-sm">Phone Number</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.branchId}>
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.address ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.phoneNumber ?? "—"}</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", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
{item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</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,16 @@
// Branch configuration. Singleton - no id. When `branchingEnabled` is off, the
// Employee/Warehouse create forms stop asking for a branch and every new record is
// silently assigned to the deployment's one default branch server-side instead.
import { apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { BranchConfig, UpdateBranchConfigRequest } from "@/types/hrm"
export const branchConfigApi = {
get(): Promise<ApiResult<BranchConfig>> {
return apiRequestWithETag<BranchConfig>("/branch-config")
},
update(request: UpdateBranchConfigRequest, ifMatch: string): Promise<ApiResult<BranchConfig>> {
return apiRequestWithETag<BranchConfig>("/branch-config", { method: "PUT", body: request, ifMatch })
},
}
+3 -3
View File
@@ -1,9 +1,9 @@
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
import { apiRequest } from "@/lib/api-client"
import { apiRequest, buildQuery } from "@/lib/api-client"
import { DashboardStats } from "@/types/dashboard"
export const dashboardApi = {
stats(): Promise<DashboardStats> {
return apiRequest<DashboardStats>("/dashboard/stats")
stats(params: { branchId?: number } = {}): Promise<DashboardStats> {
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
},
}

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