branch crud and intergration
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
+7880
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7876
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7881
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeEmployeeBranchRequired : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Branch becomes a required field on Employee going forward. Any pre-existing
|
||||||
|
// employee left with no branch is backfilled to a "Main Branch" placeholder
|
||||||
|
// (created only if it doesn't already exist) so the NOT NULL below never fails
|
||||||
|
// on live data.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
|
||||||
|
SELECT 'BR-000', 'Main Branch', 'Active', now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
|
||||||
|
AND EXISTS (SELECT 1 FROM hr_employees WHERE ""BranchId"" IS NULL);
|
||||||
|
|
||||||
|
UPDATE hr_employees
|
||||||
|
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
|
||||||
|
WHERE ""BranchId"" IS NULL;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "BranchId",
|
||||||
|
table: "hr_employees",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "BranchId",
|
||||||
|
table: "hr_employees",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWarehouseBranch : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BranchId",
|
||||||
|
table: "warehouses",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Backfill any pre-existing warehouse onto the same "Main Branch" placeholder
|
||||||
|
// used by the earlier MakeEmployeeBranchRequired migration (created only if it
|
||||||
|
// doesn't already exist) — keeps a single de-facto default rather than minting
|
||||||
|
// a second one — before the column is locked to NOT NULL below.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
|
||||||
|
SELECT 'BR-000', 'Main Branch', 'Active', now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
|
||||||
|
AND EXISTS (SELECT 1 FROM warehouses WHERE ""BranchId"" IS NULL);
|
||||||
|
|
||||||
|
UPDATE warehouses
|
||||||
|
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
|
||||||
|
WHERE ""BranchId"" IS NULL;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "BranchId",
|
||||||
|
table: "warehouses",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_warehouses_BranchId",
|
||||||
|
table: "warehouses",
|
||||||
|
column: "BranchId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_warehouses_hr_branches_BranchId",
|
||||||
|
table: "warehouses",
|
||||||
|
column: "BranchId",
|
||||||
|
principalTable: "hr_branches",
|
||||||
|
principalColumn: "BranchId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_warehouses_hr_branches_BranchId",
|
||||||
|
table: "warehouses");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_warehouses_BranchId",
|
||||||
|
table: "warehouses");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BranchId",
|
||||||
|
table: "warehouses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBranchConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "branch_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BranchingEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_branch_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_branch_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_branch_config_UpdatedBy",
|
||||||
|
table: "branch_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "branch_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -297,6 +297,10 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(200)
|
.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",
|
||||||
@@ -5906,6 +5958,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 +5973,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.HasKey("WarehouseId");
|
b.HasKey("WarehouseId");
|
||||||
|
|
||||||
|
b.HasIndex("BranchId");
|
||||||
|
|
||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -6053,6 +6110,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 +6255,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 +7748,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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -132,7 +145,7 @@ export default function ChequeBooksPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
{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 text-muted-foreground">{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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -132,6 +147,9 @@ export default function ReceivedChequesPage() {
|
|||||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
<TableCell className="px-3 py-3.5 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 text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
|
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-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 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -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,12 +5,13 @@ 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"
|
||||||
@@ -31,10 +32,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 +55,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 +68,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 +113,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 +142,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 +196,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 +204,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 +212,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 +220,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">
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,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 +43,8 @@ const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72]
|
|||||||
const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58]
|
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 +56,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 +78,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])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||||
@@ -9,6 +9,7 @@ import { grnsApi } from "@/lib/api/grns"
|
|||||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
@@ -18,7 +19,9 @@ import { cn } from "@/lib/utils"
|
|||||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -174,10 +177,13 @@ function emptyLine(): DraftLine {
|
|||||||
|
|
||||||
export default function NewGrnPage() {
|
export default function NewGrnPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>("po")
|
const [mode, setMode] = useState<Mode>("po")
|
||||||
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||||
@@ -246,6 +252,31 @@ export default function NewGrnPage() {
|
|||||||
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
|
// Admin-only branch picker, narrowing which warehouses show up below — non-admins never
|
||||||
|
// see this control, their warehouse choices are narrowed to their own branch directly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (branchingEnabled && isAdmin) {
|
||||||
|
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
}
|
||||||
|
}, [branchingEnabled, isAdmin])
|
||||||
|
|
||||||
|
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
|
||||||
|
// the dropdown only appears when the header itself is on "All Branches", so there's no
|
||||||
|
// branch to pin to and the Admin must pick one explicitly for this GRN.
|
||||||
|
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||||
|
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||||
|
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||||
|
const visibleWarehouses = useMemo(
|
||||||
|
() => (effectiveBranchId === null ? (warehouses ?? []) : (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)),
|
||||||
|
[warehouses, effectiveBranchId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switching branch can strip the currently-picked warehouse out of the visible set —
|
||||||
|
// clear it rather than silently submitting a warehouse from the previous branch.
|
||||||
|
useEffect(() => {
|
||||||
|
setWarehouseId((prev) => (prev !== null && !visibleWarehouses.some((w) => w.warehouseId === prev) ? null : prev))
|
||||||
|
}, [visibleWarehouses])
|
||||||
|
|
||||||
function switchMode(next: Mode) {
|
function switchMode(next: Mode) {
|
||||||
setMode(next)
|
setMode(next)
|
||||||
setPoId(null)
|
setPoId(null)
|
||||||
@@ -571,6 +602,30 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{branchingEnabled && isAdmin && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Branch</Label>
|
||||||
|
{showBranchPicker ? (
|
||||||
|
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="All branches" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||||
|
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Warehouse</Label>
|
<Label className="text-base">Warehouse</Label>
|
||||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||||
@@ -578,7 +633,7 @@ export default function NewGrnPage() {
|
|||||||
<SelectValue placeholder="Select warehouse" />
|
<SelectValue placeholder="Select warehouse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(warehouses ?? []).map((w) => (
|
{visibleWarehouses.map((w) => (
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
{w.code} — {w.name}
|
{w.code} — {w.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { 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 { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -29,6 +30,8 @@ type StatusFilter = GrnStatus | "All"
|
|||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 5
|
||||||
|
|
||||||
export default function GrnListPage() {
|
export default function GrnListPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
const [grns, setGrns] = useState<GrnSummary[] | 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)
|
||||||
@@ -48,7 +51,7 @@ export default function GrnListPage() {
|
|||||||
// Any filter change starts back at page 1.
|
// Any filter change starts back at page 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [query, status])
|
}, [query, status, activeBranchId])
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -58,6 +61,7 @@ export default function GrnListPage() {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setGrns(res.items)
|
setGrns(res.items)
|
||||||
@@ -66,7 +70,7 @@ export default function GrnListPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, query, status])
|
useEffect(load, [page, query, status, activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
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 { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -22,8 +23,10 @@ import { customersApi } from "@/lib/api/customers"
|
|||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
|
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
|
||||||
import { BundleSaleTemplateSummary } from "@/types/bundles"
|
import { BundleSaleTemplateSummary } from "@/types/bundles"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -90,11 +93,14 @@ function NewSalesInvoiceContent() {
|
|||||||
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
|
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
|
||||||
const appliedBundleRef = useRef<string | null>(null)
|
const appliedBundleRef = useRef<string | null>(null)
|
||||||
const bundleGroupSeqRef = useRef(0)
|
const bundleGroupSeqRef = useRef(0)
|
||||||
|
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
||||||
@@ -157,6 +163,32 @@ function NewSalesInvoiceContent() {
|
|||||||
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin-only branch picker, narrowing which warehouses show up below — non-admins never
|
||||||
|
// see this control, their warehouse choices are narrowed to their own branch directly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (branchingEnabled && isAdmin) {
|
||||||
|
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
}
|
||||||
|
}, [branchingEnabled, isAdmin])
|
||||||
|
|
||||||
|
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
|
||||||
|
// the dropdown only appears when the header itself is on "All Branches", so there's no
|
||||||
|
// branch to pin to and the Admin must pick one explicitly for this sale.
|
||||||
|
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||||
|
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||||
|
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||||
|
const visibleWarehouses = useMemo(
|
||||||
|
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
|
||||||
|
[warehouses, effectiveBranchId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switching branch can strip the currently-picked warehouse out of the visible set —
|
||||||
|
// clear it (and every line's warehouse) rather than silently submitting a mismatched one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visibleWarehouses])
|
||||||
|
|
||||||
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
|
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
|
||||||
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
|
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
|
||||||
}
|
}
|
||||||
@@ -422,6 +454,30 @@ function NewSalesInvoiceContent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{branchingEnabled && isAdmin && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Branch</Label>
|
||||||
|
{showBranchPicker ? (
|
||||||
|
<Select value={branchFilterId ? String(branchFilterId) : ""} onValueChange={(v) => setBranchFilterId(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="All branches" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<SelectItem key={b.branchId} value={String(b.branchId)} className="text-base">
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||||
|
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Warehouse</Label>
|
<Label className="text-base">Warehouse</Label>
|
||||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
||||||
@@ -429,7 +485,7 @@ function NewSalesInvoiceContent() {
|
|||||||
<SelectValue placeholder="Select warehouse" />
|
<SelectValue placeholder="Select warehouse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{warehouses.map((w) => (
|
{visibleWarehouses.map((w) => (
|
||||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||||
{w.code} — {w.name}
|
{w.code} — {w.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter,
|
|||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
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 { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -28,6 +29,8 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function SalesInvoicesPage() {
|
export default function SalesInvoicesPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
const [rows, setRows] = useState<SalesInvoiceSummary[] | 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)
|
||||||
@@ -44,18 +47,23 @@ export default function SalesInvoicesPage() {
|
|||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
useEffect(() => setPage(1), [query, status])
|
useEffect(() => setPage(1), [query, status, activeBranchId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
salesApi
|
salesApi
|
||||||
.listInvoices({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
.listInvoices({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
status: status === "All" ? undefined : status,
|
||||||
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRows(res.items)
|
setRows(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [page, status])
|
}, [page, status, activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
async function handleConfirm(row: SalesInvoiceSummary) {
|
async function handleConfirm(row: SalesInvoiceSummary) {
|
||||||
setConfirmingId(row.salesInvoiceId)
|
setConfirmingId(row.salesInvoiceId)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { MapPin } from "lucide-react"
|
||||||
|
|
||||||
|
import { branchConfigApi } from "@/lib/api/branch-config"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { BranchConfig } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch Configuration — the singleton toggle for whether this deployment uses branches
|
||||||
|
* at all. Off hides every branch picker/badge across the app (Employee, Warehouse, the
|
||||||
|
* header switcher, Sales filtering); existing records keep whatever branch they already
|
||||||
|
* have, and new ones are silently assigned to the deployment's one default branch.
|
||||||
|
*/
|
||||||
|
export default function BranchConfigurationPage() {
|
||||||
|
const [config, setConfig] = useState<BranchConfig | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setError(null)
|
||||||
|
branchConfigApi
|
||||||
|
.get()
|
||||||
|
.then((res) => {
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
async function toggle(next: boolean) {
|
||||||
|
if (!config) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await branchConfigApi.update({ branchingEnabled: next }, etag ?? "")
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save configuration", errorMessage(err))
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Branch Configuration</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Switch multi-branch mode on or off for this deployment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||||
|
|
||||||
|
{!error && config && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Branches</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.branchingEnabled}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Require branches"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Require branches</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: Employee and Warehouse records must pick a branch, and Admins can switch between
|
||||||
|
branches from the header. Off: branch pickers disappear everywhere and every record
|
||||||
|
is kept on the deployment's one default branch behind the scenes.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.updatedAt && (
|
||||||
|
<p className="pt-2 text-sm text-muted-foreground">
|
||||||
|
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||||
|
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BranchMasterPage } from "@/components/settings/BranchMasterPage"
|
||||||
|
|
||||||
|
export default function BranchesPage() {
|
||||||
|
return <BranchMasterPage />
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Building2, LayoutTemplate, SlidersHorizontal } from "lucide-react"
|
import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-react"
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
@@ -8,6 +8,12 @@ const cards = [
|
|||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
description: "Configure product and master-data options",
|
description: "Configure product and master-data options",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Branch Configuration",
|
||||||
|
href: "/dashboard/settings/branch-configuration",
|
||||||
|
icon: MapPin,
|
||||||
|
description: "Turn multi-branch mode on or off for this deployment",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Invoice Template",
|
title: "Invoice Template",
|
||||||
href: "/dashboard/settings/invoice-template",
|
href: "/dashboard/settings/invoice-template",
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BellRing, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
import { BellRing, MapPin, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "Branches",
|
||||||
|
href: "/dashboard/settings/branches",
|
||||||
|
icon: MapPin,
|
||||||
|
description: "Manage company branches/locations",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Roles",
|
title: "Roles",
|
||||||
href: "/dashboard/settings/roles",
|
href: "/dashboard/settings/roles",
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
|||||||
import { rolesApi } from "@/lib/api/roles"
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { employeeCrossLinkApi } from "@/lib/api/employees"
|
import { employeeCrossLinkApi } from "@/lib/api/employees"
|
||||||
|
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } 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 { Role } from "@/types/rbac"
|
import { Role } from "@/types/rbac"
|
||||||
import { EmployeeMatch } from "@/types/hrm"
|
import { Branch, Department, Designation, EmployeeMatch, EmploymentType, WorkShift } from "@/types/hrm"
|
||||||
import { ManagedUser, UserTypeOption } from "@/types/users"
|
import { ManagedUser, UserTypeOption } from "@/types/users"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -34,6 +36,13 @@ import { toast } from "@/components/ui/toast"
|
|||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||||
|
// Same "don't ask if it's already implied" heuristic as the Employees form: an Admin's
|
||||||
|
// currently-selected header branch, or a non-admin's own branch.
|
||||||
|
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||||
|
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
||||||
|
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||||
|
|
||||||
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
@@ -52,6 +61,25 @@ export default function UsersPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Non-admin roles must be scoped to one branch (see UserManagementService.CreateAsync) —
|
||||||
|
// these fields build the Employee record that carries it.
|
||||||
|
const selectedRole = roles.find((r) => String(r.roleId) === roleId)
|
||||||
|
const requiresBranchScope = !!roleId && selectedRole?.code?.toUpperCase() !== "ADMIN"
|
||||||
|
|
||||||
|
const [employeeCode, setEmployeeCode] = useState("")
|
||||||
|
const [hireDate, setHireDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [departmentId, setDepartmentId] = useState(0)
|
||||||
|
const [designationId, setDesignationId] = useState(0)
|
||||||
|
const [employmentTypeId, setEmploymentTypeId] = useState(0)
|
||||||
|
const [workShiftId, setWorkShiftId] = useState(0)
|
||||||
|
const [branchId, setBranchId] = useState(0)
|
||||||
|
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
|
const [designations, setDesignations] = useState<Designation[]>([])
|
||||||
|
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
||||||
|
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
|
||||||
// Advisory cross-link suggestion: does a Staff record already exist with this email?
|
// Advisory cross-link suggestion: does a Staff record already exist with this email?
|
||||||
const [staffMatch, setStaffMatch] = useState<EmployeeMatch | null>(null)
|
const [staffMatch, setStaffMatch] = useState<EmployeeMatch | null>(null)
|
||||||
const [checkingEmail, setCheckingEmail] = useState(false)
|
const [checkingEmail, setCheckingEmail] = useState(false)
|
||||||
@@ -98,6 +126,21 @@ export default function UsersPage() {
|
|||||||
.catch(() => setUserTypes([]))
|
.catch(() => setUserTypes([]))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
|
||||||
|
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
|
||||||
|
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
|
||||||
|
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
|
||||||
|
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
}, [branchingEnabled])
|
||||||
|
|
||||||
|
// Once a non-admin role is picked, default the branch to whatever's already implied
|
||||||
|
// (an Admin's selected header branch, or a non-admin creator's own branch) instead of
|
||||||
|
// making them pick it again.
|
||||||
|
useEffect(() => {
|
||||||
|
if (requiresBranchScope && lockedBranchId && !branchId) setBranchId(lockedBranchId)
|
||||||
|
}, [requiresBranchScope, lockedBranchId, branchId])
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setUsername("")
|
setUsername("")
|
||||||
setFullName("")
|
setFullName("")
|
||||||
@@ -106,6 +149,13 @@ export default function UsersPage() {
|
|||||||
setNic("")
|
setNic("")
|
||||||
setRoleId("")
|
setRoleId("")
|
||||||
setUserTypeId("")
|
setUserTypeId("")
|
||||||
|
setEmployeeCode("")
|
||||||
|
setHireDate(new Date().toISOString().slice(0, 10))
|
||||||
|
setDepartmentId(0)
|
||||||
|
setDesignationId(0)
|
||||||
|
setEmploymentTypeId(0)
|
||||||
|
setWorkShiftId(0)
|
||||||
|
setBranchId(0)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setStaffMatch(null)
|
setStaffMatch(null)
|
||||||
}
|
}
|
||||||
@@ -117,6 +167,17 @@ export default function UsersPage() {
|
|||||||
if (!email.trim()) nextErrors.email = "Email is required"
|
if (!email.trim()) nextErrors.email = "Email is required"
|
||||||
if (!roleId) nextErrors.roleId = "Role is required"
|
if (!roleId) nextErrors.roleId = "Role is required"
|
||||||
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
||||||
|
// A matched existing Staff record already carries a branch — only ask for a fresh
|
||||||
|
// one to be created when there's no such link.
|
||||||
|
if (requiresBranchScope && !staffMatch) {
|
||||||
|
if (!employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
|
||||||
|
if (!hireDate) nextErrors.hireDate = "Hire date is required"
|
||||||
|
if (!departmentId) nextErrors.departmentId = "Department is required"
|
||||||
|
if (!designationId) nextErrors.designationId = "Designation is required"
|
||||||
|
if (!employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
|
||||||
|
if (!workShiftId) nextErrors.workShiftId = "Work shift is required"
|
||||||
|
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
||||||
|
}
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
@@ -131,6 +192,17 @@ export default function UsersPage() {
|
|||||||
roleId: Number(roleId),
|
roleId: Number(roleId),
|
||||||
userTypeId: userTypeId.trim(),
|
userTypeId: userTypeId.trim(),
|
||||||
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
|
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
|
||||||
|
...(requiresBranchScope && !staffMatch
|
||||||
|
? {
|
||||||
|
employeeCode: employeeCode.trim(),
|
||||||
|
hireDate,
|
||||||
|
departmentId,
|
||||||
|
designationId,
|
||||||
|
employmentTypeId,
|
||||||
|
workShiftId,
|
||||||
|
branchId: branchId || null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -164,7 +236,7 @@ export default function UsersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New user</DialogTitle>
|
<DialogTitle>New user</DialogTitle>
|
||||||
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
||||||
@@ -225,6 +297,73 @@ export default function UsersPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
{requiresBranchScope && !staffMatch && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
This role isn't Admin — it must be scoped to one branch. These details create the linked staff record.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.employeeCode}>
|
||||||
|
<FieldLabel htmlFor="u-empcode">Employee code</FieldLabel>
|
||||||
|
<Input id="u-empcode" value={employeeCode} onChange={(e) => setEmployeeCode(e.target.value)} aria-invalid={!!errors.employeeCode} />
|
||||||
|
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.hireDate}>
|
||||||
|
<FieldLabel htmlFor="u-hire">Hire date</FieldLabel>
|
||||||
|
<Input id="u-hire" type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} aria-invalid={!!errors.hireDate} />
|
||||||
|
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.departmentId}>
|
||||||
|
<FieldLabel htmlFor="u-dept">Department</FieldLabel>
|
||||||
|
<Select value={departmentId ? String(departmentId) : undefined} onValueChange={(v) => setDepartmentId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="u-dept" aria-invalid={!!errors.departmentId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.designationId}>
|
||||||
|
<FieldLabel htmlFor="u-desig">Designation</FieldLabel>
|
||||||
|
<Select value={designationId ? String(designationId) : undefined} onValueChange={(v) => setDesignationId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="u-desig" aria-invalid={!!errors.designationId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.employmentTypeId}>
|
||||||
|
<FieldLabel htmlFor="u-etype">Employment type</FieldLabel>
|
||||||
|
<Select value={employmentTypeId ? String(employmentTypeId) : undefined} onValueChange={(v) => setEmploymentTypeId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="u-etype" aria-invalid={!!errors.employmentTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.workShiftId}>
|
||||||
|
<FieldLabel htmlFor="u-shift">Work shift</FieldLabel>
|
||||||
|
<Select value={workShiftId ? String(workShiftId) : undefined} onValueChange={(v) => setWorkShiftId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="u-shift" aria-invalid={!!errors.workShiftId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
{branchingEnabled && (
|
||||||
|
<Field data-invalid={!!errors.branchId}>
|
||||||
|
<FieldLabel htmlFor="u-branch">Branch</FieldLabel>
|
||||||
|
{showBranchPicker ? (
|
||||||
|
<Select value={branchId ? String(branchId) : undefined} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="u-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div id="u-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||||
|
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Field data-invalid={!!errors.userTypeId}>
|
<Field data-invalid={!!errors.userTypeId}>
|
||||||
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
||||||
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
||||||
|
|||||||
@@ -12,26 +12,33 @@ import { cn } from "@/lib/utils"
|
|||||||
import { StockAdjustmentSummary } from "@/types/stock"
|
import { StockAdjustmentSummary } from "@/types/stock"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
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 { AdjustmentStatusBadge } from "@/components/stock/status-badges"
|
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
|
||||||
|
|
||||||
export default function AdjustmentsListPage() {
|
export default function AdjustmentsListPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
|
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
|
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([stockAdjustmentsApi.list({ pageSize: 50 }), warehousesApi.list(), reasonCodesApi.list("Adjustment")])
|
Promise.all([
|
||||||
|
stockAdjustmentsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
|
||||||
|
warehousesApi.list(),
|
||||||
|
reasonCodesApi.list("Adjustment"),
|
||||||
|
])
|
||||||
.then(([a, wh, rc]) => {
|
.then(([a, wh, rc]) => {
|
||||||
setAdjustments(a.items)
|
setAdjustments(a.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
|
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||||
|
|
||||||
|
|||||||
@@ -11,24 +11,30 @@ import { cn } from "@/lib/utils"
|
|||||||
import { StockCountSummary } from "@/types/stock"
|
import { StockCountSummary } from "@/types/stock"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
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 { CountStatusBadge } from "@/components/stock/status-badges"
|
import { CountStatusBadge } from "@/components/stock/status-badges"
|
||||||
|
|
||||||
export default function CountsListPage() {
|
export default function CountsListPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
|
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([stockCountsApi.list({ pageSize: 50 }), warehousesApi.list()])
|
Promise.all([
|
||||||
|
stockCountsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
|
||||||
|
warehousesApi.list(),
|
||||||
|
])
|
||||||
.then(([c, wh]) => {
|
.then(([c, wh]) => {
|
||||||
setCounts(c.items)
|
setCounts(c.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { OnHand } from "@/types/stock"
|
import { OnHand } from "@/types/stock"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
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"
|
||||||
|
|
||||||
export default function StockEnquiryPage() {
|
export default function StockEnquiryPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [rows, setRows] = useState<OnHand[] | null>(null)
|
const [rows, setRows] = useState<OnHand[] | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
@@ -43,6 +46,7 @@ export default function StockEnquiryPage() {
|
|||||||
const term = search.trim().toLowerCase()
|
const term = search.trim().toLowerCase()
|
||||||
return rows
|
return rows
|
||||||
.filter((r) => warehouseId === "All" || r.warehouseId === warehouseId)
|
.filter((r) => warehouseId === "All" || r.warehouseId === warehouseId)
|
||||||
|
.filter((r) => !branchingEnabled || activeBranchId === null || warehousesById.get(r.warehouseId)?.branchId === activeBranchId)
|
||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
if (!term) return true
|
if (!term) return true
|
||||||
const item = itemsById.get(r.itemId)
|
const item = itemsById.get(r.itemId)
|
||||||
@@ -50,7 +54,7 @@ export default function StockEnquiryPage() {
|
|||||||
return haystack.includes(term)
|
return haystack.includes(term)
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
|
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
|
||||||
}, [rows, search, warehouseId, itemsById])
|
}, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const loading = rows === null || items === null || warehouses === null
|
const loading = rows === null || items === null || warehouses === null
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { LedgerEntry } from "@/types/stock"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
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 { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -23,6 +24,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
export default function StockLedgerPage() {
|
export default function StockLedgerPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [entries, setEntries] = useState<LedgerEntry[] | null>(null)
|
const [entries, setEntries] = useState<LedgerEntry[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
@@ -46,7 +49,7 @@ export default function StockLedgerPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [itemId, warehouseId, from, to])
|
}, [itemId, warehouseId, from, to, activeBranchId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -54,6 +57,7 @@ export default function StockLedgerPage() {
|
|||||||
.ledger({
|
.ledger({
|
||||||
itemId: itemId === "All" ? undefined : itemId,
|
itemId: itemId === "All" ? undefined : itemId,
|
||||||
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
||||||
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
from: from || undefined,
|
from: from || undefined,
|
||||||
to: to || undefined,
|
to: to || undefined,
|
||||||
page,
|
page,
|
||||||
@@ -70,7 +74,7 @@ export default function StockLedgerPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [itemId, warehouseId, from, to, page])
|
}, [itemId, warehouseId, from, to, page, activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { ReorderAlert } from "@/types/stock"
|
import { ReorderAlert } from "@/types/stock"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
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"
|
||||||
|
|
||||||
export default function ReorderAlertsPage() {
|
export default function ReorderAlertsPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
@@ -24,14 +27,18 @@ export default function ReorderAlertsPage() {
|
|||||||
const [requested, setRequested] = useState<Set<string>>(new Set())
|
const [requested, setRequested] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
Promise.all([
|
||||||
|
stockApi.reorderAlerts(undefined, { branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list(),
|
||||||
|
])
|
||||||
.then(([a, it, wh]) => {
|
.then(([a, it, wh]) => {
|
||||||
setAlerts(a.items)
|
setAlerts(a.items)
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||||
|
|||||||
@@ -11,24 +11,30 @@ import { cn } from "@/lib/utils"
|
|||||||
import { StockTransferSummary } from "@/types/stock"
|
import { StockTransferSummary } from "@/types/stock"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
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 { TransferStatusBadge } from "@/components/stock/status-badges"
|
import { TransferStatusBadge } from "@/components/stock/status-badges"
|
||||||
|
|
||||||
export default function TransfersListPage() {
|
export default function TransfersListPage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
|
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([stockTransfersApi.list({ pageSize: 50 }), warehousesApi.list()])
|
Promise.all([
|
||||||
|
stockTransfersApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }),
|
||||||
|
warehousesApi.list(),
|
||||||
|
])
|
||||||
.then(([t, wh]) => {
|
.then(([t, wh]) => {
|
||||||
setTransfers(t.items)
|
setTransfers(t.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { Valuation } from "@/types/stock"
|
import { Valuation } from "@/types/stock"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
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"
|
||||||
@@ -19,6 +20,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
function ValuationContent() {
|
function ValuationContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
@@ -50,6 +52,10 @@ function ValuationContent() {
|
|||||||
}, [itemId, warehouseId])
|
}, [itemId, warehouseId])
|
||||||
|
|
||||||
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
|
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
|
||||||
|
const visibleWarehouses = useMemo(
|
||||||
|
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
|
||||||
|
[warehouses, activeBranchId, branchingEnabled]
|
||||||
|
)
|
||||||
|
|
||||||
function setParam(key: "itemId" | "warehouseId", value: number) {
|
function setParam(key: "itemId" | "warehouseId", value: number) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
@@ -98,7 +104,7 @@ function ValuationContent() {
|
|||||||
<SelectValue placeholder="Select a warehouse" />
|
<SelectValue placeholder="Select a warehouse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(warehouses ?? []).map((w) => (
|
{visibleWarehouses.map((w) => (
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
{w.code} — {w.name}
|
{w.code} — {w.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { ReasonCode } from "@/types/stock"
|
import { ReasonCode } from "@/types/stock"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -21,6 +22,8 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
export default function WastagePage() {
|
export default function WastagePage() {
|
||||||
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [records, setRecords] = useState<WastageRecord[] | null>(null)
|
const [records, setRecords] = useState<WastageRecord[] | null>(null)
|
||||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
@@ -44,11 +47,12 @@ export default function WastagePage() {
|
|||||||
wastageApi
|
wastageApi
|
||||||
.list({
|
.list({
|
||||||
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
||||||
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
|
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
|
||||||
})
|
})
|
||||||
.then(setRecords)
|
.then(setRecords)
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [warehouseId, reasonCodeId])
|
}, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ export default function WarehouseDetailPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
|
||||||
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
|
<p className="text-base text-muted-foreground">
|
||||||
|
{warehouse.name}
|
||||||
|
{warehouse.branchName && ` · ${warehouse.branchName}`} · Bin/location structure (FR-WH-01, FR-MD-07)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import Link from "next/link"
|
|||||||
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||||
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { Bin, Warehouse } from "@/types/master-data"
|
import { Bin, Warehouse } from "@/types/master-data"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
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"
|
||||||
@@ -42,12 +46,16 @@ function generateWarehouseCode(name: string, existingCodes: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WarehousesPage() {
|
export default function WarehousesPage() {
|
||||||
|
const { branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [bins, setBins] = useState<Bin[] | null>(null)
|
const [bins, setBins] = useState<Bin[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
|
const [branchId, setBranchId] = useState(0)
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
@@ -65,6 +73,9 @@ export default function WarehousesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
}, [branchingEnabled])
|
||||||
|
|
||||||
function binCount(warehouseId: number) {
|
function binCount(warehouseId: number) {
|
||||||
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
|
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
|
||||||
@@ -73,15 +84,17 @@ export default function WarehousesPage() {
|
|||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
||||||
|
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const warehouse = await warehousesApi.create({ code: generatedCode, name })
|
const warehouse = await warehousesApi.create({ code: generatedCode, name, branchId })
|
||||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
|
setBranchId(0)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -129,6 +142,16 @@ export default function WarehousesPage() {
|
|||||||
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
|
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
|
||||||
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||||
</Field>
|
</Field>
|
||||||
|
{branchingEnabled && (
|
||||||
|
<Field data-invalid={!!errors.branchId}>
|
||||||
|
<FieldLabel htmlFor="wh-branch">Branch</FieldLabel>
|
||||||
|
<Select value={String(branchId)} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
|
||||||
|
<SelectTrigger id="wh-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</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">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
@@ -167,6 +190,7 @@ export default function WarehousesPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -179,6 +203,7 @@ export default function WarehousesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
|
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{w.branchName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
ListTree,
|
ListTree,
|
||||||
Lock,
|
Lock,
|
||||||
|
MapPin,
|
||||||
Menu,
|
Menu,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -212,6 +213,7 @@ const navItems: {
|
|||||||
icon: Settings,
|
icon: Settings,
|
||||||
chevron: true,
|
chevron: true,
|
||||||
children: [
|
children: [
|
||||||
|
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
|
||||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
|
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
import { ArrowLeft, Bell, Building2, LogOut, Settings, User } from "lucide-react"
|
||||||
|
|
||||||
import { authApi } from "@/lib/api/auth"
|
import { authApi } from "@/lib/api/auth"
|
||||||
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { AuthUser } from "@/types/auth"
|
import { AuthUser } from "@/types/auth"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
|
||||||
/** Avatar initials from a display name: "ERP Admin" -> "EA", "erpadmin" -> "ER". */
|
/** Avatar initials from a display name: "ERP Admin" -> "EA", "erpadmin" -> "ER". */
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
@@ -166,9 +170,27 @@ export function Header() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const title = titleFromPath(pathname)
|
const title = titleFromPath(pathname)
|
||||||
const showBackButton = pathname !== "/dashboard"
|
const showBackButton = pathname !== "/dashboard"
|
||||||
|
const { isAdmin, branchName, activeBranchId, activeBranchName, setActiveBranch, branchingEnabled } = useAuth()
|
||||||
|
const branchLabel = isAdmin ? (activeBranchName ?? "All Branches") : branchName
|
||||||
const [notifications, setNotifications] = useState(initialNotifications)
|
const [notifications, setNotifications] = useState(initialNotifications)
|
||||||
const unreadCount = notifications.filter((n) => n.unread).length
|
const unreadCount = notifications.filter((n) => n.unread).length
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin || !branchingEnabled) return
|
||||||
|
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
}, [isAdmin, branchingEnabled])
|
||||||
|
|
||||||
|
const ALL_BRANCHES = "__all__"
|
||||||
|
function handleBranchChange(value: string | null) {
|
||||||
|
if (!value || value === ALL_BRANCHES) {
|
||||||
|
setActiveBranch(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const branch = branches.find((b) => String(b.branchId) === value)
|
||||||
|
setActiveBranch(branch?.branchId ?? null, branch?.name ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
// Read after mount so the first client render matches the server render.
|
// Read after mount so the first client render matches the server render.
|
||||||
const [user, setUser] = useState<AuthUser | null>(null)
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
|
||||||
@@ -213,6 +235,36 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||||
|
{branchingEnabled && (isAdmin ? (
|
||||||
|
<Select value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES} onValueChange={handleBranchChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="hidden h-8! shrink-0 gap-1.5 rounded-full border-transparent bg-muted px-2.5 text-xs font-medium text-muted-foreground hover:bg-muted/70 sm:flex"
|
||||||
|
aria-label="Switch branch"
|
||||||
|
>
|
||||||
|
<Building2 className="size-3.5" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<SelectItem key={b.branchId} value={String(b.branchId)}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
branchLabel && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="hidden shrink-0 items-center gap-1.5 border-transparent bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground sm:flex"
|
||||||
|
>
|
||||||
|
<Building2 className="size-3.5" />
|
||||||
|
{branchLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -298,6 +350,12 @@ export function Header() {
|
|||||||
{user ? displayName(user) : "Signed in"}
|
{user ? displayName(user) : "Signed in"}
|
||||||
</p>
|
</p>
|
||||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||||
|
{branchLabel && (
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-sm font-normal text-muted-foreground">
|
||||||
|
<Building2 className="size-3.5" />
|
||||||
|
{branchLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -9,10 +9,53 @@ import { createContext, useContext, useEffect, useState } from "react"
|
|||||||
import { authApi } from "@/lib/api/auth"
|
import { authApi } from "@/lib/api/auth"
|
||||||
import { MeResponse } from "@/types/rbac"
|
import { MeResponse } from "@/types/rbac"
|
||||||
|
|
||||||
|
interface ActiveBranch {
|
||||||
|
id: number | null
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin's last-picked header branch, kept per-browser (docs/BRANCH_ISOLATION_PLAN.md
|
||||||
|
* §Frontend impact: "a current branch selector... persisted per session"). Purely a
|
||||||
|
* display/filter convenience — never trusted server-side. */
|
||||||
|
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
|
||||||
|
|
||||||
|
function readStoredActiveBranch(): ActiveBranch | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as ActiveBranch
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(ACTIVE_BRANCH_KEY)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
roleCode: string | null
|
roleCode: string | null
|
||||||
roleName: string | null
|
roleName: string | null
|
||||||
navCodes: string[]
|
navCodes: string[]
|
||||||
|
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
|
||||||
|
isAdmin: boolean
|
||||||
|
/** The caller's own branch, via their linked Employee record — null if unlinked. */
|
||||||
|
branchId: number | null
|
||||||
|
branchName: string | null
|
||||||
|
/**
|
||||||
|
* The branch currently being "worked in": for non-admins this always mirrors their own
|
||||||
|
* branch; for an Admin it's whichever branch they last picked in the header (null = "All
|
||||||
|
* Branches"), persisted per-browser. Screens that want to filter by "the branch the user
|
||||||
|
* is currently viewing" should read this, not `branchId`.
|
||||||
|
*/
|
||||||
|
activeBranchId: number | null
|
||||||
|
activeBranchName: string | null
|
||||||
|
/** Admin-only — switches the header's active branch. A no-op for any other role. */
|
||||||
|
setActiveBranch: (id: number | null, name: string | null) => void
|
||||||
|
/**
|
||||||
|
* The deployment-wide toggle (Settings → Configuration → Branch Configuration). False
|
||||||
|
* means this deployment doesn't use branches at all — every branch picker/badge in the
|
||||||
|
* app should be hidden entirely, not merely restricted by role.
|
||||||
|
*/
|
||||||
|
branchingEnabled: boolean
|
||||||
/** True until the first `/auth/me` response lands. */
|
/** True until the first `/auth/me` response lands. */
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
@@ -21,11 +64,19 @@ const AuthContext = createContext<AuthContextValue>({
|
|||||||
roleCode: null,
|
roleCode: null,
|
||||||
roleName: null,
|
roleName: null,
|
||||||
navCodes: [],
|
navCodes: [],
|
||||||
|
isAdmin: false,
|
||||||
|
branchId: null,
|
||||||
|
branchName: null,
|
||||||
|
activeBranchId: null,
|
||||||
|
activeBranchName: null,
|
||||||
|
setActiveBranch: () => {},
|
||||||
|
branchingEnabled: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [me, setMe] = useState<MeResponse | null>(null)
|
const [me, setMe] = useState<MeResponse | null>(null)
|
||||||
|
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -37,19 +88,39 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [] })
|
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true })
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (me === null) return
|
||||||
|
// Non-admins are always pinned to their own branch — never their own past picks.
|
||||||
|
setActiveBranchState(me.isAdmin ? (readStoredActiveBranch() ?? { id: null, name: null }) : { id: me.branchId, name: me.branchName })
|
||||||
|
}, [me])
|
||||||
|
|
||||||
|
function setActiveBranch(id: number | null, name: string | null) {
|
||||||
|
if (!me?.isAdmin) return
|
||||||
|
const next = { id, name }
|
||||||
|
setActiveBranchState(next)
|
||||||
|
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
roleCode: me?.roleCode ?? null,
|
roleCode: me?.roleCode ?? null,
|
||||||
roleName: me?.roleName ?? null,
|
roleName: me?.roleName ?? null,
|
||||||
navCodes: me?.navCodes ?? [],
|
navCodes: me?.navCodes ?? [],
|
||||||
|
isAdmin: me?.isAdmin ?? false,
|
||||||
|
branchId: me?.branchId ?? null,
|
||||||
|
branchName: me?.branchName ?? null,
|
||||||
|
activeBranchId: activeBranch?.id ?? null,
|
||||||
|
activeBranchName: activeBranch?.name ?? null,
|
||||||
|
setActiveBranch,
|
||||||
|
branchingEnabled: me?.branchingEnabled ?? true,
|
||||||
loading: me === null,
|
loading: me === null,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Dedicated Branch list/create/deactivate screen — Branch diverges from the plain
|
||||||
|
// "code + name" HRM masters (CodeNameMasterPage): its code is server-generated
|
||||||
|
// (never entered), and it carries Address + Phone Number too. Shared by both
|
||||||
|
// entry points (Settings → Branches and HRM → Settings → Branches) since Branch
|
||||||
|
// is a cross-cutting concept now, not HRM-specific.
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export function BranchMasterPage() {
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const [items, setItems] = useState<Branch[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [address, setAddress] = useState("")
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("")
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
branchesHrmApi
|
||||||
|
.list({ page, pageSize: PAGE_SIZE })
|
||||||
|
.then((res) => {
|
||||||
|
setItems(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [page])
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setName("")
|
||||||
|
setAddress("")
|
||||||
|
setPhoneNumber("")
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!name.trim()) nextErrors.name = "Name is required"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await branchesHrmApi.create({
|
||||||
|
name: name.trim(),
|
||||||
|
address: address.trim() || null,
|
||||||
|
phoneNumber: phoneNumber.trim() || null,
|
||||||
|
})
|
||||||
|
toast.success("Branch created")
|
||||||
|
setOpen(false)
|
||||||
|
resetForm()
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not create branch", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(item: Branch) {
|
||||||
|
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
||||||
|
try {
|
||||||
|
await branchesHrmApi.updateStatus(item.branchId, next)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Branches</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Company branches/locations — used for multi-branch scoping.</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) resetForm() }}>
|
||||||
|
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>New Branch</DialogTitle>
|
||||||
|
<DialogDescription>Code is generated automatically. Deactivate later — branches are never deleted.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="branch-name">Name</FieldLabel>
|
||||||
|
<Input id="branch-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="branch-address">Address</FieldLabel>
|
||||||
|
<Input id="branch-address" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="branch-phone">Phone Number</FieldLabel>
|
||||||
|
<Input id="branch-phone" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!error && items === null && (
|
||||||
|
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No branches yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length > 0 && (
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Address</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Phone Number</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.branchId}>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.address ?? "—"}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.phoneNumber ?? "—"}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
|
||||||
|
{item.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Branch configuration. Singleton - no id. When `branchingEnabled` is off, the
|
||||||
|
// Employee/Warehouse create forms stop asking for a branch and every new record is
|
||||||
|
// silently assigned to the deployment's one default branch server-side instead.
|
||||||
|
import { apiRequestWithETag } from "@/lib/api-client"
|
||||||
|
import { ApiResult } from "@/types/common"
|
||||||
|
import { BranchConfig, UpdateBranchConfigRequest } from "@/types/hrm"
|
||||||
|
|
||||||
|
export const branchConfigApi = {
|
||||||
|
get(): Promise<ApiResult<BranchConfig>> {
|
||||||
|
return apiRequestWithETag<BranchConfig>("/branch-config")
|
||||||
|
},
|
||||||
|
|
||||||
|
update(request: UpdateBranchConfigRequest, ifMatch: string): Promise<ApiResult<BranchConfig>> {
|
||||||
|
return apiRequestWithETag<BranchConfig>("/branch-config", { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
|
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
|
||||||
import { apiRequest } from "@/lib/api-client"
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
import { DashboardStats } from "@/types/dashboard"
|
import { DashboardStats } from "@/types/dashboard"
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
stats(): Promise<DashboardStats> {
|
stats(params: { branchId?: number } = {}): Promise<DashboardStats> {
|
||||||
return apiRequest<DashboardStats>("/dashboard/stats")
|
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user