Compare commits

..

3 Commits

Author SHA1 Message Date
Naveen 149b87bfeb Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-15 15:24:48 +05:30
ImanThiyanga b3b6a25ce8 Merge pull request 'Branch crud' (#52) from branch-crud into Dev
Reviewed-on: #52
2026-09-08 07:13:47 +00:00
Sasanka f664961fce branch crud and intergration 2026-09-08 12:13:49 +05:30
173 changed files with 50531 additions and 1337 deletions
@@ -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> /// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")] [HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct) public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct)); => Ok(await _dashboard.GetStatsAsync(branchId, ct));
} }
@@ -22,8 +22,8 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List( public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId, [FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) [FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct)); => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")] [HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -25,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status, [FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId, [FromQuery] int? customerId,
[FromQuery] int? warehouseId, [FromQuery] int? warehouseId,
[FromQuery] int? branchId,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List( public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct)); => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary> /// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")] [HttpGet("{adjustmentId:int}")]
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")] [HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList( public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct)); => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary> /// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did /// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")] [HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger( public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct) [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")] [HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")] [HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts( public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct)); => Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary> /// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")] [HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List( public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct)); => Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")] [HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List( public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status, [FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct) [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct)); => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")] [HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
+14 -2
View File
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<UomDto>>> List(
=> Ok(await _uoms.ListAsync(query, ct)); [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +28,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct); var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto); return Created($"/api/v1/uoms/{dto.UomId}", dto);
} }
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
} }
@@ -1,6 +1,7 @@
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -15,11 +16,13 @@ public sealed class UsersController : ApiControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links) public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
{ {
_users = users; _users = users;
_links = links; _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> /// <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)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct) 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); return Created($"/api/v1/users/{result.UserId}", result);
} }
+5 -2
View File
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced /// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>. /// <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. /// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary> /// </summary>
public class Branch public class Branch
{ {
public int BranchId { get; set; } 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 Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Address { get; set; } public string? Address { get; set; }
public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; } 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 Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; } public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; } public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; } public int BranchId { get; set; }
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public int WorkShiftId { get; set; } public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; } public WorkShift? WorkShift { get; set; }
+6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
@@ -10,4 +12,8 @@ public class Uom
{ {
public int UomId { get; set; } public int UomId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
} }
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
public string Name { 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>(); 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( public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email, int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName, 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); EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto( public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country, string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone, string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate, 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? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); 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 DepartmentId { get; set; }
[Required] public int DesignationId { get; set; } [Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { 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; } [Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; } public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; } [Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; } [Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { 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; } [Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { 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). // ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto( 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); DateTime CreatedAt, DateTime? UpdatedAt);
// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest public sealed class CreateBranchRequest
{ {
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; } [StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
} }
public sealed class UpdateBranchRequest public sealed class UpdateBranchRequest
{ {
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; } [StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
} }
public sealed record DepartmentDto( 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 /// <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 /// for the current user's role and permitted sidebar nav codes (replaces trusting
/// the stale, client-only `roleId` cached in localStorage).</summary> /// the stale, client-only `roleId` cached in localStorage). <see cref="BranchId"/>/
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes); /// <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);
+7 -1
View File
@@ -1,11 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms; namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name); public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateUomRequest public sealed class CreateUomRequest
{ {
[Required, StringLength(50)] public string Name { get; set; } = string.Empty; [Required, StringLength(50)] public string Name { get; set; } = string.Empty;
} }
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+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). /// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
/// </summary> /// </summary>
public int? LinkEmployeeId { get; set; } 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 public sealed class UpdateUserRoleRequest
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses; namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary> /// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). <see cref="BranchId"/> is
public sealed record WarehouseDto(int WarehouseId, string Code, string Name); /// 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> /// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); 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(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public int BranchId { get; set; }
} }
public sealed class CreateBinRequest 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 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> /// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; } 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.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200); builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500); builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.PhoneNumber).HasMaxLength(30);
builder.Property(b => b.Status) builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired() .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 = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 }, new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }, 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 = 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 = 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 = 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 = 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 = 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 }, new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -13,5 +14,13 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50); builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique(); builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
} }
} }
@@ -15,5 +15,9 @@ public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
builder.HasIndex(w => w.Code).IsUnique(); builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200); 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 SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct); // dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct); dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct); dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct); dirty |= await SeedSalesStockAsync(db, ct);
@@ -110,6 +111,23 @@ public static class DataSeeder
return true; 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) private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{ {
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false; 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> /// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>(); 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> /// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>(); public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>(); 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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
@@ -297,6 +297,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("PhoneNumber")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -323,6 +327,38 @@ namespace ERPCore.Migrations
b.ToTable("hr_branches", (string)null); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
{ {
b.Property<int>("BrandId") b.Property<int>("BrandId")
@@ -890,7 +926,7 @@ namespace ERPCore.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<int?>("BranchId") b.Property<int>("BranchId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("City") b.Property<string>("City")
@@ -2943,6 +2979,12 @@ namespace ERPCore.Migrations
PermissionId = 39, PermissionId = 39,
Code = "NAV:procurement.purchase-invoices", Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28 SubNavItemId = 28
},
new
{
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
}); });
}); });
@@ -5394,6 +5436,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 29,
Code = "settings.branches",
Href = "/dashboard/settings/branches",
Label = "Branches",
NavItemId = 9,
SortOrder = 0,
Status = "Active"
},
new
{ {
SubNavItemId = 7, SubNavItemId = 7,
Code = "settings.roles", Code = "settings.roles",
@@ -5707,16 +5759,31 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId"); b.HasKey("UomId");
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null); b.ToTable("uoms", (string)null);
}); });
@@ -5906,6 +5973,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("Code") b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -5918,6 +5988,8 @@ namespace ERPCore.Migrations
b.HasKey("WarehouseId"); b.HasKey("WarehouseId");
b.HasIndex("BranchId");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique();
@@ -6053,6 +6125,16 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
@@ -6188,7 +6270,8 @@ namespace ERPCore.Migrations
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany() .WithMany()
.HasForeignKey("BranchId") .HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Department", "Department") b.HasOne("ERPCore.Domain.Entities.Department", "Department")
.WithMany() .WithMany()
@@ -7680,6 +7763,17 @@ namespace ERPCore.Migrations
b.Navigation("Quotation"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>(); builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>(); //builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>(); builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>(); builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>(); builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>(); builder.Services.AddScoped<IVendorService, VendorService>();
@@ -95,7 +95,7 @@ public sealed class AdjustmentService : IAdjustmentService
} }
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( 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(); var q = _adjustments.Query().AsNoTracking();
@@ -105,6 +105,7 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%")); q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
} }
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId); 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); if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct); 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( 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(); 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 (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId) var rows = await q.OrderByDescending(c => c.CountId)
+21 -11
View File
@@ -40,18 +40,19 @@ public sealed class DashboardService : IDashboardService
_reorder = reorder; _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, // Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations. // which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; var layers = _layers.Query().AsNoTracking();
var onHandWarehouses = await _layers.Query().AsNoTracking() if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking() var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), 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 // EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record. // 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) .GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total) .OrderByDescending(w => w.Total)
@@ -59,17 +60,26 @@ public sealed class DashboardService : IDashboardService
var stockValuationByWarehouse = stockValuationByWarehouseRaw var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) .Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList(); .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() var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking() var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); .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); 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. // 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; .Pagination.TotalItems;
return new DashboardStatsDto( return new DashboardStatsDto(
+4 -1
View File
@@ -73,7 +73,7 @@ public sealed class GrnService : IGrnService
} }
public async Task<PagedResponse<GrnSummaryDto>> ListAsync( 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(); 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 (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId); if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId) 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 total = await q.CountAsync(ct);
var rows = await q.OrderBy(b => b.Name) var rows = await q.OrderBy(b => b.Name)
.Skip(query.Skip).Take(query.PageSize) .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); .ToListAsync(ct);
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total); 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) 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 var branch = new Branch
{ {
Code = code, Code = await GenerateCodeAsync(ct),
Name = request.Name.Trim(), Name = request.Name.Trim(),
Address = request.Address?.Trim(), Address = request.Address?.Trim(),
PhoneNumber = request.PhoneNumber?.Trim(),
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -69,6 +66,28 @@ public sealed class BranchService : IBranchService
return new ETagged<BranchDto>(Map(branch), branch.RowVersion); 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) public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
@@ -79,6 +98,7 @@ public sealed class BranchService : IBranchService
branch.Name = request.Name.Trim(); branch.Name = request.Name.Trim();
branch.Address = request.Address?.Trim(); branch.Address = request.Address?.Trim();
branch.PhoneNumber = request.PhoneNumber?.Trim();
branch.UpdatedAt = DateTime.UtcNow; branch.UpdatedAt = DateTime.UtcNow;
try try
@@ -103,5 +123,5 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct); 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<Employee> _employees;
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails; private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public EmployeeService( 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; _employees = employees;
_users = users; _users = users;
_bankDetails = bankDetails; _bankDetails = bankDetails;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow; _uow = uow;
} }
@@ -66,7 +71,10 @@ public sealed class EmployeeService : IEmployeeService
public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default) public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default)
{ {
var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct); 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) 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; linkedUserId = user.UserId;
} }
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var employee = new Employee var employee = new Employee
{ {
EmployeeCode = code, EmployeeCode = code,
@@ -107,7 +117,7 @@ public sealed class EmployeeService : IEmployeeService
DepartmentId = request.DepartmentId, DepartmentId = request.DepartmentId,
DesignationId = request.DesignationId, DesignationId = request.DesignationId,
EmploymentTypeId = request.EmploymentTypeId, EmploymentTypeId = request.EmploymentTypeId,
BranchId = request.BranchId, BranchId = branchId,
WorkShiftId = request.WorkShiftId, WorkShiftId = request.WorkShiftId,
ReportingManagerId = request.ReportingManagerId, ReportingManagerId = request.ReportingManagerId,
EpfNumber = request.EpfNumber?.Trim(), EpfNumber = request.EpfNumber?.Trim(),
@@ -131,6 +141,8 @@ public sealed class EmployeeService : IEmployeeService
var employee = await _employees.GetByIdAsync(employeeId, ct) var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found."); ?? throw new NotFoundException($"Employee {employeeId} was not found.");
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
if (employee.RowVersion != expectedRowVersion) if (employee.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); 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.DepartmentId = request.DepartmentId;
employee.DesignationId = request.DesignationId; employee.DesignationId = request.DesignationId;
employee.EmploymentTypeId = request.EmploymentTypeId; employee.EmploymentTypeId = request.EmploymentTypeId;
employee.BranchId = request.BranchId; employee.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
employee.WorkShiftId = request.WorkShiftId; employee.WorkShiftId = request.WorkShiftId;
employee.ReportingManagerId = request.ReportingManagerId; employee.ReportingManagerId = request.ReportingManagerId;
employee.EpfNumber = request.EpfNumber?.Trim(); employee.EpfNumber = request.EpfNumber?.Trim();
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface IAdjustmentService public interface IAdjustmentService
{ {
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( 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); 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 public interface ICountService
{ {
Task<PagedResponse<CountSummaryDto>> ListAsync( 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?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, 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> /// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService 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 public interface IGrnService
{ {
Task<PagedResponse<GrnSummaryDto>> ListAsync( 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?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, 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> /// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService 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); Task<RequisitionDto> CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default);
} }
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService 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<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, 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> /// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync( 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( 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); string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ITransferService public interface ITransferService
{ {
Task<PagedResponse<TransferSummaryDto>> ListAsync( 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?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default); Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
@@ -6,6 +7,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService public interface IUomService
{ {
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default); Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
} }
@@ -12,7 +12,7 @@ public interface IUserManagementService
{ {
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, 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); Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <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; _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(); var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); 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 policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>(); var alerts = new List<ReorderAlertDto>();
+26 -4
View File
@@ -4,6 +4,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac; using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
@@ -15,20 +16,28 @@ namespace ERPCore.Services;
public sealed class RoleService : IRoleService public sealed class RoleService : IRoleService
{ {
private const string AdminRoleCode = "Admin";
private readonly IRepository<Role> _roles; private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions; private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public RoleService( public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions, 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; _roles = roles;
_rolePermissions = rolePermissions; _rolePermissions = rolePermissions;
_permissions = permissions; _permissions = permissions;
_employees = employees;
_currentUser = currentUser;
_authHex = authHex; _authHex = authHex;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
} }
@@ -184,17 +193,30 @@ public sealed class RoleService : IRoleService
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default) 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)) 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 // AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an // (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. // identity code differing only by case must not lock the user out of the nav.
var normalized = roleCode.Trim(); var normalized = roleCode.Trim();
var isAdmin = string.Equals(normalized, AdminRoleCode, StringComparison.OrdinalIgnoreCase);
var role = await _roles.Query().AsNoTracking() var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct); .FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
if (role is null) 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() var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId) .Where(rp => rp.RoleId == role.RoleId)
@@ -210,7 +232,7 @@ public sealed class RoleService : IRoleService
.Distinct() .Distinct()
.ToList(); .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( private static RoleDto Map(Role r) => new(
@@ -49,7 +49,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _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); IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q)) 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 (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(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<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines; private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Warehouse> _warehouses;
public StockService( public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers, IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines) IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
{ {
_fifo = fifo; _fifo = fifo;
_layers = layers; _layers = layers;
_ledger = ledger; _ledger = ledger;
_transferLines = transferLines; _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) public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
{ {
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct); var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
@@ -58,11 +71,13 @@ public sealed class StockService : IStockService
/// warehouse simply does not appear. /// warehouse simply does not appear.
/// </summary> /// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync( 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(); var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); 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 var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId }) .GroupBy(l => new { l.ItemId, l.WarehouseId })
@@ -116,12 +131,14 @@ public sealed class StockService : IStockService
} }
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync( 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) string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{ {
var q = _ledger.Query().AsNoTracking(); var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId); if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
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 (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically // 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( 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(); 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 (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId) var rows = await q.OrderByDescending(t => t.TransferId)
+16 -4
View File
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _uoms.Query().AsNoTracking(); var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,11 +29,12 @@ public sealed class UomService : IUomService
var term = query.Q.Trim(); var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%")); q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
} }
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name) var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name)) .Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -44,10 +46,20 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct)) if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists."); throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name }; var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _uoms.AddAsync(uom, ct); await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name); return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
} }
} }
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -16,20 +17,24 @@ public sealed class UserManagementService : IUserManagementService
{ {
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles; private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers; private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public UserManagementService( public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IEmployeeUserLinkService links, IUnitOfWork uow) IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{ {
_users = users; _users = users;
_roles = roles; _roles = roles;
_employees = employees;
_authUsers = authUsers; _authUsers = authUsers;
_authHex = authHex; _authHex = authHex;
_links = links; _links = links;
_employeeService = employeeService;
_uow = uow; _uow = uow;
} }
@@ -57,11 +62,30 @@ public sealed class UserManagementService : IUserManagementService
return user is null ? null : Map(user); 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) var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found."); ?? 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(); var username = request.Username.Trim();
if (await _users.Query().AnyAsync(u => u.Username == username, ct)) if (await _users.Query().AnyAsync(u => u.Username == username, ct))
throw new ConflictException($"A user with username '{username}' already exists."); 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). // Explicit, human-confirmed link to an existing Staff record (never automatic).
if (request.LinkEmployeeId is not null) if (request.LinkEmployeeId is not null)
{
await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct); 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; user.Role = role;
return Map(user); return Map(user);
+16 -7
View File
@@ -13,18 +13,24 @@ public sealed class WarehouseService : IWarehouseService
{ {
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins; private readonly IRepository<Bin> _bins;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; 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; _warehouses = warehouses;
_bins = bins; _bins = bins;
_branches = branches;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default) 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)) if (!string.IsNullOrWhiteSpace(query.Q))
{ {
var term = query.Q.Trim(); var term = query.Q.Trim();
@@ -34,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code) var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize) .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); .ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total); 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) 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); .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) 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)) if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists."); 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 _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(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) public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)
+1 -1
View File
@@ -5,7 +5,7 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" "DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"AuthHex": { "AuthHex": {
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`} {gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -6,10 +6,12 @@ import { useParams } from "next/navigation"
import { ArrowLeft, BookText } from "lucide-react" import { ArrowLeft, BookText } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format" import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
@@ -32,10 +34,15 @@ export default function ChequeBookDetailPage() {
const [book, setBook] = useState<ChequeBook | null>(null) const [book, setBook] = useState<ChequeBook | null>(null)
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null) const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null) const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
chequeBooksApi chequeBooksApi
@@ -94,7 +101,7 @@ export default function ChequeBookDetailPage() {
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Branch</p> <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>
<div> <div>
<p className="text-sm text-muted-foreground">Range</p> <p className="text-sm text-muted-foreground">Range</p>
@@ -141,7 +148,7 @@ export default function ChequeBookDetailPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums"> <TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"} {p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell> </TableCell>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateChequeBookForm } from "@/lib/validations/general-ledger" import { validateChequeBookForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -19,9 +22,11 @@ import { toast } from "@/components/ui/toast"
export default function NewChequeBookPage() { export default function NewChequeBookPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null) const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchId, setBranchId] = useState("") const [branchId, setBranchId] = useState("")
const [bankAccountId, setBankAccountId] = useState("") const [bankAccountId, setBankAccountId] = useState("")
@@ -44,6 +49,21 @@ export default function NewChequeBookPage() {
.catch((err) => setBankAccountsError(errorMessage(err))) .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() { async function handleSubmit() {
const nextErrors = validateChequeBookForm({ const nextErrors = validateChequeBookForm({
branchId, branchId,
@@ -118,15 +138,29 @@ export default function NewChequeBookPage() {
</Field> </Field>
<Field data-invalid={!!errors.branchId}> <Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel> <FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
<Input {showBranchPicker ? (
id="cb-branch" <Select<string> value={branchId} onValueChange={(v) => setBranchId(v ?? "")}>
type="number" <SelectTrigger id="cb-branch" className="w-full text-base" aria-invalid={!!errors.branchId}>
value={branchId} <SelectValue placeholder="Select a branch" />
onChange={(e) => setBranchId(e.target.value)} </SelectTrigger>
placeholder="1" <SelectContent>
aria-invalid={!!errors.branchId} {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]} /> <FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field> </Field>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react" import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format" import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger" 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 { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -27,15 +30,24 @@ const STATUS_BADGE: Record<ChequeBookStatus, string> = {
export default function ChequeBooksPage() { export default function ChequeBooksPage() {
const router = useRouter() const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null) const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
Promise.all([ 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), bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
]) ])
.then(([result, banks]) => { .then(([result, banks]) => {
@@ -51,9 +63,10 @@ export default function ChequeBooksPage() {
} }
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches. // bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -129,15 +142,15 @@ export default function ChequeBooksPage() {
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)} onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
> >
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5">
{bank ? bank.accountName : `#${b.bankAccountId}`} {bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell> <TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo} {b.startChequeNo} – {b.endChequeNo}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell> <TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}> <Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status} {b.status}
@@ -1,27 +1,34 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger" import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ReceivedFromType } from "@/types/general-ledger" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const NONE = "__none__"
export default function NewReceivedChequePage() { export default function NewReceivedChequePage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [companyId, setCompanyId] = useState("") const [companyId, setCompanyId] = useState("")
const [branchId, setBranchId] = useState("") const [branchId, setBranchId] = useState("")
const [branches, setBranches] = useState<Branch[]>([])
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer) const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
const [receivedFromId, setReceivedFromId] = useState("") const [receivedFromId, setReceivedFromId] = useState("")
const [receivedFromName, setReceivedFromName] = useState("") const [receivedFromName, setReceivedFromName] = useState("")
@@ -39,6 +46,19 @@ export default function NewReceivedChequePage() {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) 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() { async function handleSubmit() {
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate }) const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
setErrors(nextErrors) setErrors(nextErrors)
@@ -100,8 +120,30 @@ export default function NewReceivedChequePage() {
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} /> <FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel> <FieldLabel htmlFor="rc-branch">Branch (optional)</FieldLabel>
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} /> {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>
<Field> <Field>
@@ -1,15 +1,18 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react" import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format" import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger" 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 { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -28,16 +31,25 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
} }
export default function ReceivedChequesPage() { export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null) const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [selected, setSelected] = useState<ReceivedCheque | null>(null) const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
receivedChequesApi receivedChequesApi
.list(status === "All" ? undefined : { status }) .list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => { .then((res) => {
if (!cancelled) setCheques(res.items) if (!cancelled) setCheques(res.items)
}) })
@@ -47,7 +59,9 @@ export default function ReceivedChequesPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [status]) }, [status, activeBranchId, branchingEnabled])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
function handleUpdated(updated: ReceivedCheque) { function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev)) 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">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</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">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 text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
@@ -131,9 +146,12 @@ export default function ReceivedChequesPage() {
> >
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell> <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">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell> <TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
{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-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">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}> <Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status} {c.status}
@@ -15,11 +15,12 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = { const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -137,16 +138,7 @@ export default function AttendanceBatchesPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -5,10 +5,11 @@ import { useParams } from "next/navigation"
import { Link2, Plus, Upload } from "lucide-react" import { Link2, Plus, Upload } from "lucide-react"
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees" 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 { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
Branch,
Department, Department,
Designation, Designation,
EmployeeBankDetail, EmployeeBankDetail,
@@ -23,6 +24,7 @@ import {
WorkShift, WorkShift,
} from "@/types/hrm" } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -43,6 +45,7 @@ function money(n: number): string {
export default function EmployeeDetailPage() { export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const employeeId = Number(params.id) const employeeId = Number(params.id)
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
const [tab, setTab] = useState<Tab>("Overview") const [tab, setTab] = useState<Tab>("Overview")
const [employee, setEmployee] = useState<EmployeeDetail | null>(null) const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
@@ -53,6 +56,7 @@ export default function EmployeeDetailPage() {
const [designations, setDesignations] = useState<Designation[]>([]) const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([]) const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([]) const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([]) 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([])) 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([])) 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([])) 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(() => { useEffect(() => {
if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err))) 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> <SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </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>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> <Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div> </div>
@@ -5,23 +5,25 @@ import Link from "next/link"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" 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 { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const emptyForm: CreateEmployeeRequest = { const emptyForm: CreateEmployeeRequest = {
employeeCode: "", employeeCode: "",
@@ -31,10 +33,19 @@ const emptyForm: CreateEmployeeRequest = {
departmentId: 0, departmentId: 0,
designationId: 0, designationId: 0,
employmentTypeId: 0, employmentTypeId: 0,
branchId: 0,
workShiftId: 0, workShiftId: 0,
} }
export default function EmployeesPage() { 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 [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -45,6 +56,7 @@ export default function EmployeesPage() {
const [designations, setDesignations] = useState<Designation[]>([]) const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([]) const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([]) const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm) const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
@@ -57,18 +69,22 @@ export default function EmployeesPage() {
function load() { function load() {
employeesApi 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) }) .then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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(() => { useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) 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([])) 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([])) 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([])) 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) { async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return } if (!email.trim()) { setUserMatch(null); return }
@@ -98,6 +114,7 @@ export default function EmployeesPage() {
if (!form.designationId) nextErrors.designationId = "Designation is required" if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required" if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required" if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
@@ -126,7 +143,14 @@ export default function EmployeesPage() {
<h1 className="text-2xl font-bold text-foreground">Employees</h1> <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> <p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
</div> </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>} /> <DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
@@ -173,7 +197,7 @@ export default function EmployeesPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.departmentId}> <Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel> <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> <SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -181,7 +205,7 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.designationId}> <Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="e-desig">Designation</FieldLabel> <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> <SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -189,7 +213,7 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.employmentTypeId}> <Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel> <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> <SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent> <SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -197,12 +221,28 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.workShiftId}> <Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel> <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> <SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent> <SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select> </Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} /> <FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field> </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> </div>
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
@@ -269,16 +309,7 @@ export default function EmployeesPage() {
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -16,12 +16,13 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const STATUS_STYLE: Record<LeaveRequestStatus, string> = { const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -183,16 +184,7 @@ export default function LeaveRequestsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -15,11 +15,12 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const STATUS_STYLE: Record<PayrollRunStatus, string> = { const STATUS_STYLE: Record<PayrollRunStatus, string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -124,16 +125,7 @@ export default function PayrollRunsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -1,16 +1,7 @@
"use client" "use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" import { BranchMasterPage } from "@/components/settings/BranchMasterPage"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Branch } from "@/types/hrm"
export default function BranchesPage() { export default function BranchesPage() {
return ( return <BranchMasterPage />
<CodeNameMasterPage<Branch>
title="Branches"
description="Company locations/branches — used for multi-branch employee and payroll scoping."
idOf={(b) => b.branchId}
api={branchesHrmApi}
/>
)
} }
@@ -14,12 +14,13 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const NONE = "__none__" const NONE = "__none__"
export default function DepartmentsPage() { export default function DepartmentsPage() {
@@ -183,16 +184,7 @@ export default function DepartmentsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -15,12 +15,13 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
export default function DocumentTypesPage() { export default function DocumentTypesPage() {
@@ -161,16 +162,7 @@ export default function DocumentTypesPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -15,11 +15,12 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
export default function LeaveTypesPage() { export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null) const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -164,16 +165,7 @@ export default function LeaveTypesPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -15,12 +15,13 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
export default function SalaryComponentsPage() { export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null) const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -161,16 +162,7 @@ export default function SalaryComponentsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -15,11 +15,12 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
export default function WorkShiftsPage() { export default function WorkShiftsPage() {
@@ -207,16 +208,7 @@ export default function WorkShiftsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>
) )
} }
@@ -128,7 +128,7 @@ export default function GeneralLedgerReportPage() {
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell> <TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell> <TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell> <TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell> <TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
+65 -63
View File
@@ -6,7 +6,6 @@ import {
AlertTriangle, AlertTriangle,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
Clock,
ClipboardList, ClipboardList,
ListChecks, ListChecks,
PackageCheck, PackageCheck,
@@ -22,6 +21,7 @@ import { cn } from "@/lib/utils"
import { DashboardStats } from "@/types/dashboard" import { DashboardStats } from "@/types/dashboard"
import { LedgerEntry } from "@/types/stock" import { LedgerEntry } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -42,6 +42,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] const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58]
export default function DashboardPage() { export default function DashboardPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null)
const [movements, setMovements] = useState<LedgerEntry[] | null>(null) const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null) const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
@@ -53,11 +55,12 @@ export default function DashboardPage() {
const from = new Date() const from = new Date()
from.setDate(from.getDate() - (TREND_DAYS - 1)) from.setDate(from.getDate() - (TREND_DAYS - 1))
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([ Promise.all([
dashboardApi.stats(), dashboardApi.stats({ branchId }),
stockApi.ledger({ page: 1, pageSize: 5 }), stockApi.ledger({ page: 1, pageSize: 5, branchId }),
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }), stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }),
warehousesApi.list(), warehousesApi.list(),
]) ])
.then(([statsRes, ledger, trendRes, warehousesRes]) => { .then(([statsRes, ledger, trendRes, warehousesRes]) => {
@@ -74,7 +77,7 @@ export default function DashboardPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [activeBranchId, branchingEnabled])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -124,7 +127,7 @@ export default function DashboardPage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
{loaded ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <Link href="/dashboard/stock/reorder-alerts">
@@ -133,9 +136,6 @@ export default function DashboardPage() {
<Link href="/dashboard/stock/enquiry"> <Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} /> <StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link> </Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn"> <Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} /> <StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link> </Link>
@@ -150,66 +150,68 @@ export default function DashboardPage() {
</Link> </Link>
</> </>
) : ( ) : (
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />) !error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)} )}
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base"> <div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<BadgeDollarSign className="size-4 shrink-0 text-primary" /> <h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Valuation by Warehouse <BadgeDollarSign className="size-4 shrink-0 text-primary" />
</h2> Stock Valuation by Warehouse
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}> </h2>
View details <Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
</Link> View details
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div> </div>
{loaded ? ( <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
(stats.stockValuationByWarehouse ?? []).length > 0 ? ( <div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-52 sm:h-64"> <div className="h-52 sm:h-64">
<BarChart <LineChart
labels={stats.stockValuationByWarehouse.map( labels={movementTrend.labels}
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[ datasets={[
{ { label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
label: "Stock Value (LKR)", { label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]} ]}
/> />
</div> </div>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p> !error && <Skeleton className="h-52 rounded-lg sm:h-64" />
) )}
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div> </div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
@@ -239,9 +241,9 @@ export default function DashboardPage() {
<TableBody> <TableBody>
{movements.map((entry) => ( {movements.map((entry) => (
<TableRow key={entry.ledgerId}> <TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell> <TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -256,11 +258,11 @@ export default function DashboardPage() {
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell> <TableCell>{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell>
{entry.sourceDocType} #{entry.sourceDocId} {entry.sourceDocType} #{entry.sourceDocId}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()} {new Date(entry.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{line.grnDocNo} {line.grnDocNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell> <TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell> <TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell> <TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
/> />
</TableCell> </TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell> <TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
@@ -2,12 +2,13 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -19,7 +20,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All" type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const tabs: { value: StatusFilter; label: string }[] = [ const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" }, { value: "All", label: "All" },
{ value: "Draft", label: "Draft" }, { value: "Draft", label: "Draft" },
@@ -198,10 +199,10 @@ export default function PurchaseInvoicesPage() {
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell> <TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell> <TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5"> <TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
@@ -271,22 +272,9 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</div> </div>
{pagination && pagination.totalPages > 1 && ( {pagination && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between"> <div className="border-t px-4 py-4">
<p className="text-sm text-muted-foreground"> <PaginationFooter pagination={pagination} onPageChange={setPage} />
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))}>
<ChevronLeft />
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
<ChevronRight />
</Button>
</div>
</div> </div>
)} )}
</> </>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react" import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -22,7 +23,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -260,31 +261,7 @@ export default function PurchaseOrdersListPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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))}>
<ChevronLeft />
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
<ChevronRight />
</Button>
</div>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -107,7 +107,7 @@ export default function PurchaseReturnsListPage() {
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -9,7 +9,8 @@ import { errorMessage } from "@/lib/error-map"
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement" import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -17,7 +18,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All" type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
export default function RequisitionsListPage() { export default function RequisitionsListPage() {
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null) const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
@@ -114,36 +115,14 @@ export default function RequisitionsListPage() {
<RequisitionStatusBadge status={r.status} /> <RequisitionStatusBadge status={r.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell> <TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell> <TableCell className="px-3 py-3.5">{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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> </div>

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