Compare commits

..

21 Commits

Author SHA1 Message Date
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
187 changed files with 40799 additions and 2665 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
}
/// <summary>
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
}
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
}
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
{
await _employees.DeleteAsync(employeeId, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
}
+2 -14
View File
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -16,9 +15,8 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -28,14 +26,4 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
+21 -3
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
@@ -16,12 +18,14 @@ public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
_audit = audit;
_currentUser = currentUser;
}
@@ -33,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -65,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
}
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
+4 -2
View File
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
+5
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
/// admin can see a user's device/login history — AuthHex's own session listing only
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
/// active sessions" view (that would need AuthHex to expose one).
/// </summary>
public class LoginActivity
{
public int LoginActivityId { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
public string? DeviceLabel { get; set; }
public DateTime LoginAt { get; set; }
}
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from src/dest warehouse.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
-6
View File
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
@@ -12,8 +10,4 @@ public class Uom
{
public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
+4 -1
View File
@@ -5,5 +5,8 @@ public enum AuditAction
{
Create,
Update,
Delete
Delete,
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
Login
}
+4
View File
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
public sealed record LoginActivityDto(
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
+5 -2
View File
@@ -15,13 +15,13 @@ public sealed record GrnLineDto(
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
@@ -81,6 +81,9 @@ public sealed class CreateGrnRequest
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -15,14 +15,14 @@ public sealed record SalesInvoiceTotalsDto(
public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, int BranchId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto(
@@ -65,6 +65,9 @@ public sealed class CreateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
@@ -73,6 +76,9 @@ public sealed class UpdateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
+5 -2
View File
@@ -8,13 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
@@ -31,6 +31,9 @@ public sealed class CreateAdjustmentLineInput
public sealed class CreateAdjustmentRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
+5 -2
View File
@@ -8,12 +8,12 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
@@ -23,6 +23,9 @@ public sealed record CountPostResultDto(int CountId, CountStatus Status, int? Ad
public sealed class CreateCountRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
}
+5 -2
View File
@@ -9,12 +9,12 @@ public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -42,6 +42,9 @@ public sealed class CreateTransferRequest
{
[Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; }
/// <summary>Owning branch for this transfer — warehouses are shared, so this is what
/// actually scopes the document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
+1 -7
View File
@@ -1,17 +1,11 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+5
View File
@@ -53,5 +53,10 @@ public sealed class UpdateUserRoleRequest
[Required] public int RoleId { get; set; }
}
public sealed class UpdateUserStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -0,0 +1,39 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
/// login-device history — not a full parsing library, just enough to be readable
/// (docs call this out: exact device model detection is out of scope).
/// </summary>
public static class UserAgentLabeler
{
public static string Label(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
var os = DetectOs(userAgent);
var browser = DetectBrowser(userAgent);
return $"{browser} on {os}";
}
private static string DetectOs(string ua)
{
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
return "an unknown OS";
}
private static string DetectBrowser(string ua)
{
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
return "an unknown browser";
}
}
@@ -21,7 +21,7 @@ public static class AuditScribe
private static readonly HashSet<Type> Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer),
typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
];
private static readonly JsonSerializerOptions Json = new()
@@ -22,11 +22,13 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Branch).WithMany().HasForeignKey(g => g.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
builder.HasIndex(g => g.BranchId);
}
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
{
public void Configure(EntityTypeBuilder<LoginActivity> builder)
{
builder.ToTable("login_activities");
builder.HasKey(l => l.LoginActivityId);
builder.Property(l => l.IpAddress).HasMaxLength(64);
builder.Property(l => l.UserAgent).HasMaxLength(500);
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
builder.Property(l => l.LoginAt).IsRequired();
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(l => new { l.UserId, l.LoginAt });
}
}
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
// NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
// Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
);
}
}
@@ -30,6 +30,11 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch)
.WithMany()
.HasForeignKey(x => x.BranchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.InvoiceType)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C);
@@ -47,6 +52,7 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate);
builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice)
@@ -21,7 +21,10 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Branch).WithMany().HasForeignKey(a => a.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => a.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Branch).WithMany().HasForeignKey(t => t.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BranchId);
}
}
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
// never existed in the database before now, so "stock" is still bypassed frontend-side
// in AppSidebar.tsx until roles are actually granted these permissions.
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
);
}
}
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
}
}
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>();
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260914061430_AddUomLifecycleColumnsLocal")]
partial class AddUomLifecycleColumnsLocal
[Migration("20260908101847_AddBranchIdToDocuments")]
partial class AddBranchIdToDocuments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -1450,6 +1450,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1496,6 +1499,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4190,6 +4195,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4282,6 +4290,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId");
@@ -4862,6 +4872,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4899,6 +4912,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4961,6 +4976,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType")
.IsRequired()
.HasMaxLength(20)
@@ -4993,6 +5011,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -5185,6 +5205,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -5215,6 +5238,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId");
@@ -5762,31 +5787,16 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null);
});
@@ -6398,6 +6408,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -6426,6 +6442,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
@@ -7160,6 +7178,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatorUserId");
@@ -7176,6 +7200,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Customer");
@@ -7441,6 +7467,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7459,6 +7491,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("ReasonCode");
@@ -7502,6 +7536,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7514,6 +7554,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Warehouse");
@@ -7621,6 +7663,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7639,6 +7687,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("DestWarehouse");
@@ -0,0 +1,199 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchIdToDocuments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_transfers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_counts",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_adjustments",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "sales_invoices",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "grns",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_grns_BranchId",
table: "grns",
column: "BranchId");
// Backfill from each document's existing warehouse — warehouses were the sole
// branch anchor until now, so this preserves current branch assignment exactly.
// (Warehouse.BranchId is NOT NULL, so every row is resolvable; the WHERE BranchId = 0
// guard just keeps this idempotent if the migration is ever re-run against partial state.)
migrationBuilder.Sql(@"
UPDATE grns g SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = g.""WarehouseId"" AND g.""BranchId"" = 0;
UPDATE sales_invoices x SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = x.""WarehouseId"" AND x.""BranchId"" = 0;
UPDATE stock_counts c SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = c.""WarehouseId"" AND c.""BranchId"" = 0;
UPDATE stock_adjustments a SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = a.""WarehouseId"" AND a.""BranchId"" = 0;
UPDATE stock_transfers t SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = t.""SrcWarehouseId"" AND t.""BranchId"" = 0;
");
migrationBuilder.AddForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns");
migrationBuilder.DropForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices");
migrationBuilder.DropForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments");
migrationBuilder.DropForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts");
migrationBuilder.DropForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts");
migrationBuilder.DropIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments");
migrationBuilder.DropIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_BranchId",
table: "grns");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_transfers");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_counts");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "BranchId",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "BranchId",
table: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveVendorsUnderProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Order matters: Permission.NavItemId cascades on delete, so the permission row must be
// repointed off NavItemId 3 (via the new sub_nav_item) before that nav_item is deleted —
// otherwise the delete cascades and removes the permission row first.
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 30, "procurement.vendors", "/dashboard/vendors", null, "Vendors", 4, 0 });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:procurement.vendors", null, 30 });
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 30);
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder", "Status" },
values: new object[] { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3, "Active" });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:vendors", 3, null });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddStockSubNavItems : 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[,]
{
{ 31, "stock.ledger", "/dashboard/stock/ledger", null, "Stock Ledger", 6, 1 },
{ 32, "stock.enquiry", "/dashboard/stock/enquiry", null, "Stock Enquiry", 6, 2 },
{ 33, "stock.valuation", "/dashboard/stock/valuation", null, "Valuation", 6, 3 },
{ 34, "stock.transfers", "/dashboard/stock/transfers", null, "Transfers", 6, 4 },
{ 35, "stock.adjustments", "/dashboard/stock/adjustments", null, "Adjustments", 6, 5 },
{ 36, "stock.counts", "/dashboard/stock/counts", null, "Counts", 6, 6 },
{ 37, "stock.reorder-alerts", "/dashboard/stock/reorder-alerts", null, "Reorder Alerts", 6, 7 },
{ 38, "stock.wastage", "/dashboard/stock/wastage", null, "Wastage", 6, 8 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 41, "NAV:stock.ledger", null, 31 },
{ 42, "NAV:stock.enquiry", null, 32 },
{ 43, "NAV:stock.valuation", null, 33 },
{ 44, "NAV:stock.transfers", null, 34 },
{ 45, "NAV:stock.adjustments", null, 35 },
{ 46, "NAV:stock.counts", null, 36 },
{ 47, "NAV:stock.reorder-alerts", null, 37 },
{ 48, "NAV:stock.wastage", null, 38 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 42);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 43);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 44);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 45);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 46);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 47);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 48);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 31);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 32);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 33);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 34);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 38);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesSubNavItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 49, "NAV:sales.bundle-templates", null, 23 });
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-templates", "/dashboard/sales/bundles/templates", "Bundle Templates", 2 });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 40, "sales.invoices", "/dashboard/sales/invoices", null, "Sales", 13, 1 },
{ 41, "sales.free-issues", "/dashboard/sales/free-issues", null, "Free Issues", 13, 3 },
{ 42, "sales.day-end", "/dashboard/sales/day-end", null, "Day End", 13, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 50, "NAV:sales.invoices", null, 40 },
{ 51, "NAV:sales.free-issues", null, 41 },
{ 52, "NAV:sales.day-end", null, 42 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 49);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 50);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 51);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 52);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 42);
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-sales", "/dashboard/sales/bundles", "Bundle Sales", 1 });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddLoginActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "login_activities",
columns: table => new
{
LoginActivityId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
IpAddress = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
DeviceLabel = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
LoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_login_activities", x => x.LoginActivityId);
table.ForeignKey(
name: "FK_login_activities_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_login_activities_UserId_LoginAt",
table: "login_activities",
columns: new[] { "UserId", "LoginAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "login_activities");
}
}
}
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
@@ -1447,6 +1447,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1493,6 +1496,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -2166,6 +2171,39 @@ namespace ERPCore.Migrations
b.ToTable("hr_loan_installments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.Property<int>("LoginActivityId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LoginActivityId"));
b.Property<string>("DeviceLabel")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("LoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("LoginActivityId");
b.HasIndex("UserId", "LoginAt");
b.ToTable("login_activities", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
{
b.Property<int>("NavItemId")
@@ -2229,15 +2267,6 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
NavItemId = 3,
Code = "vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
SortOrder = 3,
Status = "Active"
},
new
{
NavItemId = 4,
Code = "procurement",
@@ -2761,8 +2790,8 @@ namespace ERPCore.Migrations
new
{
PermissionId = 3,
Code = "NAV:vendors",
NavItemId = 3
Code = "NAV:procurement.vendors",
SubNavItemId = 30
},
new
{
@@ -2985,6 +3014,78 @@ namespace ERPCore.Migrations
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
},
new
{
PermissionId = 41,
Code = "NAV:stock.ledger",
SubNavItemId = 31
},
new
{
PermissionId = 42,
Code = "NAV:stock.enquiry",
SubNavItemId = 32
},
new
{
PermissionId = 43,
Code = "NAV:stock.valuation",
SubNavItemId = 33
},
new
{
PermissionId = 44,
Code = "NAV:stock.transfers",
SubNavItemId = 34
},
new
{
PermissionId = 45,
Code = "NAV:stock.adjustments",
SubNavItemId = 35
},
new
{
PermissionId = 46,
Code = "NAV:stock.counts",
SubNavItemId = 36
},
new
{
PermissionId = 47,
Code = "NAV:stock.reorder-alerts",
SubNavItemId = 37
},
new
{
PermissionId = 48,
Code = "NAV:stock.wastage",
SubNavItemId = 38
},
new
{
PermissionId = 49,
Code = "NAV:sales.bundle-templates",
SubNavItemId = 23
},
new
{
PermissionId = 50,
Code = "NAV:sales.invoices",
SubNavItemId = 40
},
new
{
PermissionId = 51,
Code = "NAV:sales.free-issues",
SubNavItemId = 41
},
new
{
PermissionId = 52,
Code = "NAV:sales.day-end",
SubNavItemId = 42
});
});
@@ -4187,6 +4288,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4279,6 +4383,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId");
@@ -4859,6 +4965,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4896,6 +5005,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4958,6 +5069,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType")
.IsRequired()
.HasMaxLength(20)
@@ -4990,6 +5104,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -5182,6 +5298,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -5212,6 +5331,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId");
@@ -5477,15 +5598,55 @@ namespace ERPCore.Migrations
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
SubNavItemId = 40,
Code = "sales.invoices",
Href = "/dashboard/sales/invoices",
Label = "Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-templates",
Href = "/dashboard/sales/bundles/templates",
Label = "Bundle Templates",
NavItemId = 13,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 41,
Code = "sales.free-issues",
Href = "/dashboard/sales/free-issues",
Label = "Free Issues",
NavItemId = 13,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 42,
Code = "sales.day-end",
Href = "/dashboard/sales/day-end",
Label = "Day End",
NavItemId = 13,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 30,
Code = "procurement.vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
NavItemId = 4,
SortOrder = 0,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -5664,6 +5825,86 @@ namespace ERPCore.Migrations
NavItemId = 14,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 31,
Code = "stock.ledger",
Href = "/dashboard/stock/ledger",
Label = "Stock Ledger",
NavItemId = 6,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 32,
Code = "stock.enquiry",
Href = "/dashboard/stock/enquiry",
Label = "Stock Enquiry",
NavItemId = 6,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 33,
Code = "stock.valuation",
Href = "/dashboard/stock/valuation",
Label = "Valuation",
NavItemId = 6,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 34,
Code = "stock.transfers",
Href = "/dashboard/stock/transfers",
Label = "Transfers",
NavItemId = 6,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 35,
Code = "stock.adjustments",
Href = "/dashboard/stock/adjustments",
Label = "Adjustments",
NavItemId = 6,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 36,
Code = "stock.counts",
Href = "/dashboard/stock/counts",
Label = "Counts",
NavItemId = 6,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 37,
Code = "stock.reorder-alerts",
Href = "/dashboard/stock/reorder-alerts",
Label = "Reorder Alerts",
NavItemId = 6,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 38,
Code = "stock.wastage",
Href = "/dashboard/stock/wastage",
Label = "Wastage",
NavItemId = 6,
SortOrder = 8,
Status = "Active"
});
});
@@ -5759,31 +6000,16 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null);
});
@@ -6395,6 +6621,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -6423,6 +6655,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
@@ -6600,6 +6834,17 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun");
});
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -7157,6 +7402,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatorUserId");
@@ -7173,6 +7424,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Customer");
@@ -7438,6 +7691,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7456,6 +7715,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("ReasonCode");
@@ -7499,6 +7760,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7511,6 +7778,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Warehouse");
@@ -7618,6 +7887,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7636,6 +7911,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("DestWarehouse");
+10 -4
View File
@@ -32,6 +32,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -41,7 +42,7 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_adjustments = adjustments;
@@ -52,6 +53,7 @@ public sealed class AdjustmentService : IAdjustmentService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -105,14 +107,15 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
.ToListAsync(ct);
@@ -145,6 +148,8 @@ public sealed class AdjustmentService : IAdjustmentService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment)
@@ -169,6 +174,7 @@ public sealed class AdjustmentService : IAdjustmentService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -196,7 +202,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
+56 -1
View File
@@ -1,7 +1,10 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
{
private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal;
private readonly IRepository<User> _users;
private readonly IRepository<LoginActivity> _loginActivities;
private readonly IUnitOfWork _uow;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
public AuditService(
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
{
_logs = logs;
_journal = journal;
_users = users;
_loginActivities = loginActivities;
_uow = uow;
}
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
{
var userId = await _users.Query().AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => (int?)u.UserId)
.FirstOrDefaultAsync(ct);
// JIT provisioning may not have created the local shadow row yet on a brand-new
// account's very first login — nothing to attach the log to in that case.
if (userId is null) return;
var now = DateTime.UtcNow;
await _logs.AddAsync(new AuditLog
{
UserId = userId.Value,
EntityType = "User",
EntityId = userId.Value,
Action = AuditAction.Login,
ChangeSet = "{}",
CreatedAt = now
}, ct);
await _loginActivities.AddAsync(new LoginActivity
{
UserId = userId.Value,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceLabel = UserAgentLabeler.Label(userAgent),
LoginAt = now
}, ct);
await _uow.SaveChangesAsync(ct);
}
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
{
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LoginActivityId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
.ToListAsync(ct);
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
}
}
+11 -4
View File
@@ -34,6 +34,7 @@ public sealed class CountService : ICountService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -43,7 +44,7 @@ public sealed class CountService : ICountService
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_counts = counts;
@@ -55,6 +56,7 @@ public sealed class CountService : ICountService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -108,13 +110,14 @@ public sealed class CountService : ICountService
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the count carries its own owning BranchId.
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
@@ -133,6 +136,8 @@ public sealed class CountService : ICountService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
@@ -153,6 +158,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
CountType = request.CountType,
Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -222,6 +228,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -250,7 +257,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+2 -2
View File
@@ -62,11 +62,11 @@ public sealed class DashboardService : IDashboardService
.ToList();
var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
if (branchId is not null) grns = grns.Where(g => g.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);
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
+11 -7
View File
@@ -38,6 +38,7 @@ public sealed class GrnService : IGrnService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -49,8 +50,8 @@ public sealed class GrnService : IGrnService
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_grns = grns;
_pos = pos;
@@ -65,6 +66,7 @@ public sealed class GrnService : IGrnService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
@@ -86,15 +88,14 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -116,6 +117,8 @@ public sealed class GrnService : IGrnService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
int vendorId;
@@ -211,6 +214,7 @@ public sealed class GrnService : IGrnService
PoId = request.PoId,
VendorId = vendorId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
@@ -448,7 +452,7 @@ public sealed class GrnService : IGrnService
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
_branches.Remove(branch);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
}
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
}
@@ -127,6 +127,22 @@ public sealed class DepartmentService : IDepartmentService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int departmentId, CancellationToken ct = default)
{
var dept = await _departments.GetByIdAsync(departmentId, ct)
?? throw new NotFoundException($"Department {departmentId} was not found.");
_departments.Remove(dept);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This department is referenced by other records (sub-departments or employees) and cannot be deleted.", 409);
}
}
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
{
@@ -101,5 +101,21 @@ public sealed class DesignationService : IDesignationService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int designationId, CancellationToken ct = default)
{
var designation = await _designations.GetByIdAsync(designationId, ct)
?? throw new NotFoundException($"Designation {designationId} was not found.");
_designations.Remove(designation);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This designation is referenced by other records and cannot be deleted.", 409);
}
}
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
}
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IRepository<EmployeeDocument> _documents;
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<LeaveBalance> _leaveBalances;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IFileStorageService _fileStorage;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_documents = documents;
_salaryStructures = salaryStructures;
_loans = loans;
_leaveBalances = leaveBalances;
_payrollLines = payrollLines;
_fileStorage = fileStorage;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow;
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
// A linked system-user account survives the employee row (Employee.UserId → User is
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
// with no staff record behind it. The User row itself is never removed.
if (employee.UserId is not null)
{
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
}
// Bank details and documents cascade at the DB level, but documents also have a
// physical file on disk that cascading the row would silently orphan — delete those
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
// they must be removed before the employee row itself will delete.
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
foreach (var doc in documents)
{
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
_documents.Remove(doc);
}
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
foreach (var loan in loans) _loans.Remove(loan);
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
_employees.Remove(employee);
await _uow.SaveChangesAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
{
return await _bankDetails.Query().AsNoTracking()
@@ -101,5 +101,21 @@ public sealed class EmploymentTypeService : IEmploymentTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employmentTypeId, CancellationToken ct = default)
{
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
_employmentTypes.Remove(et);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This employment type is referenced by other records and cannot be deleted.", 409);
}
}
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
}
@@ -108,6 +108,22 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This document type is referenced by other records and cannot be deleted.", 409);
}
}
private static HrDocumentTypeDto Map(HrDocumentType t) => new(
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -43,6 +43,11 @@ public sealed class LeaveRequestService : ILeaveRequestService
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
if (status is not null) q = q.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.CreatedAt)
@@ -113,6 +113,22 @@ public sealed class LeaveTypeService : ILeaveTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int leaveTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This leave type is referenced by other records and cannot be deleted.", 409);
}
}
private static LeaveTypeDto Map(LeaveType t) => new(
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
@@ -107,6 +107,22 @@ public sealed class SalaryComponentService : ISalaryComponentService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int salaryComponentId, CancellationToken ct = default)
{
var component = await _components.GetByIdAsync(salaryComponentId, ct)
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
_components.Remove(component);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This salary component is referenced by other records and cannot be deleted.", 409);
}
}
private static SalaryComponentDto Map(SalaryComponent c) => new(
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
}
@@ -120,6 +120,22 @@ public sealed class WorkShiftService : IWorkShiftService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int workShiftId, CancellationToken ct = default)
{
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
_shifts.Remove(shift);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This work shift is referenced by other records and cannot be deleted.", 409);
}
}
private static WorkShiftDto Map(WorkShift w) => new(
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API.
/// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
/// </summary>
public interface IAuditService
{
@@ -14,4 +17,12 @@ public interface IAuditService
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
/// AuthHex identity, if one exists yet.</summary>
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IBranchService
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int branchId, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IDepartmentService
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int departmentId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface IDesignationService
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int designationId, CancellationToken ct = default);
}
@@ -15,6 +15,14 @@ public interface IEmployeeService
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
/// <summary>
/// Permanently deletes the employee and every dependent record (bank details, documents
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
/// 409 if the employee has any payroll history, since that's financial/audit record that
/// must never be destroyed.
/// </summary>
Task DeleteAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface IEmploymentTypeService
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int employmentTypeId, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IHrDocumentTypeService
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface ILeaveTypeService
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int leaveTypeId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface ISalaryComponentService
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int salaryComponentId, CancellationToken ct = default);
}
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the
/// credential itself stays live upstream; this only revokes the account's
/// standing inside ERPCore.
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
}
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService
{
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
@@ -12,4 +12,5 @@ public interface IWorkShiftService
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int workShiftId, CancellationToken ct = default);
}
@@ -26,6 +26,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
@@ -33,7 +34,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
ICurrentUser currentUser, IBranchConfigService branchConfig, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
_customers = customers;
@@ -45,6 +46,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_branchConfig = branchConfig;
_numbers = numbers;
_uow = uow;
}
@@ -60,9 +62,8 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -83,6 +84,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -96,6 +98,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -119,6 +122,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
invoice.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -201,5 +205,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
}
@@ -31,7 +31,7 @@ public sealed class SalesMappingService : ISalesMappingService
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new(
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, invoice.BranchId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
invoice.GlJournalNo, invoice.GlPostedAt,
MapInvoiceTotals(invoice),
+10 -6
View File
@@ -34,11 +34,13 @@ public sealed class TransferService : ITransferService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
@@ -46,6 +48,7 @@ public sealed class TransferService : ITransferService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -62,15 +65,14 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side
// rather than only the branch that happens to hold the source warehouse.
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
// Warehouses are shared across branches — the transfer carries its own owning BranchId.
if (branchId is not null) q = q.Where(t => t.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
@@ -90,6 +92,7 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
@@ -103,6 +106,7 @@ public sealed class TransferService : ITransferService
DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId,
BranchId = branchId,
Status = TransferStatus.Draft,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
@@ -227,7 +231,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}
+4 -16
View File
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
var uom = new Uom { Name = name };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
}
}
@@ -18,27 +18,25 @@ public sealed class UserManagementService : IUserManagementService
private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_employees = employees;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_employeeService = employeeService;
_uow = uow;
}
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
var authUserId = Guid.NewGuid();
// Source of truth: AuthHex creates the credential + emails it (registerUser,
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
await _authUsers.RegisterAsync(new RegisterRequest
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
// AuthHex client directly rather than IAuthUserService.RegisterAsync — that
// wrapper is shaped for self-registration (AuthController.Register) and demands
// a session (access/refresh tokens) in the response, which makes no sense here:
// an admin creating an account for someone else never logs in as them, so
// AuthHex's registerUser response for this case carries no tokens to demand.
await _authHex.RegisterAsync(new RegisterRequest
{
UserId = authUserId,
RoleId = role.AuthRoleId,
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
user.Status = status;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+2 -1
View File
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
{
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
}
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
@@ -47,6 +47,7 @@ public static class ErrorCodes
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
+1 -1
View File
@@ -5,7 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5 text-muted-foreground">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
<Tooltip>
<TooltipTrigger
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell>
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
@@ -146,12 +146,12 @@ export default function ReceivedChequesPage() {
>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
<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">{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">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
@@ -15,12 +15,11 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
@@ -138,7 +137,16 @@ export default function AttendanceBatchesPage() {
</TableBody>
</Table>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Link2, Plus, Upload } from "lucide-react"
import { useParams, useSearchParams } from "next/navigation"
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
@@ -25,13 +25,14 @@ import {
} from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -44,6 +45,7 @@ function money(n: number): string {
export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const employeeId = Number(params.id)
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
@@ -51,6 +53,7 @@ export default function EmployeeDetailPage() {
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "1")
const [departments, setDepartments] = useState<Department[]>([])
const [designations, setDesignations] = useState<Designation[]>([])
@@ -58,8 +61,11 @@ export default function EmployeeDetailPage() {
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [saving, setSaving] = useState(false)
const [statusPending, setStatusPending] = useState(false)
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
const [bankDraft, setBankDraft] = useState<EmployeeBankDetail[]>([])
const [savingBank, setSavingBank] = useState(false)
const [documents, setDocuments] = useState<EmployeeDocument[]>([])
const [documentTypes, setDocumentTypes] = useState<HrDocumentType[]>([])
const [uploadTypeId, setUploadTypeId] = useState<string>("")
@@ -100,7 +106,11 @@ export default function EmployeeDetailPage() {
}, [branchingEnabled])
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((items) => { setBankDetails(items); setBankDraft(items.map((b) => ({ ...b }))) })
.catch((err) => toast.error("Could not load bank details", errorMessage(err)))
}
if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err)))
if (tab === "Salary & Loans") {
employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err)))
@@ -109,6 +119,15 @@ export default function EmployeeDetailPage() {
}
}, [tab, employeeId])
// The active structure has no `effectiveTo`; fall back to the most recent one if none is marked Active yet.
const currentStructure = salaryStructures.find((s) => s.status === "Active") ?? salaryStructures[0]
const componentTypeById = new Map(salaryComponents.map((c) => [c.salaryComponentId, c.componentType]))
const structureLinesTyped = currentStructure?.lines ?? []
const earningLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) !== "Deduction")
const deductionLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) === "Deduction")
const grossPay = (currentStructure?.basicSalary ?? 0) + earningLines.reduce((sum, l) => sum + l.amount, 0)
const netPay = grossPay - deductionLines.reduce((sum, l) => sum + l.amount, 0)
function addStructureLine() {
setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }])
}
@@ -190,6 +209,7 @@ export default function EmployeeDetailPage() {
}, etag)
setEmployee(res.data)
setEtag(res.etag)
setIsEditing(false)
toast.success("Employee updated")
} catch (err) {
toast.error("Could not save", errorMessage(err))
@@ -198,6 +218,62 @@ export default function EmployeeDetailPage() {
}
}
function handleCancelEdit() {
setIsEditing(false)
setBankDraft(bankDetails.map((b) => ({ ...b })))
load()
}
// "Inactive" here maps to Suspended — a reversible off-switch, distinct from the
// one-way separation statuses (Resigned/Terminated/Retired) which need their own flow.
async function handleToggleStatus() {
if (!employee) return
const nextStatus = employee.status === "Active" ? "Suspended" : "Active"
setStatusPending(true)
try {
await employeesApi.updateStatus(employeeId, nextStatus)
setEmployee({ ...employee, status: nextStatus })
toast.success(nextStatus === "Active" ? "Employee activated" : "Employee deactivated")
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setStatusPending(false)
}
}
const BLANK_BANK_DETAIL: EmployeeBankDetail = {
employeeBankDetailId: null, bankName: "", branchName: "", accountNumber: "", accountHolderName: "", swiftCode: null, isPrimary: true, status: "Active",
}
function updateBankRow(index: number, patch: Partial<EmployeeBankDetail>) {
setBankDraft((prev) => {
const base = prev[index] ?? BLANK_BANK_DETAIL
const next = [...prev]
next[index] = { ...base, ...patch }
return next
})
}
async function handleSaveBank() {
const b = bankDraft[0]
if (!b || !b.bankName.trim() || !b.branchName.trim() || !b.accountNumber.trim() || !b.accountHolderName.trim()) {
toast.error("Bank name, branch, account number, and account holder name are required")
return
}
setSavingBank(true)
try {
const saved = await employeesApi.replaceBankDetails(employeeId, bankDraft)
setBankDetails(saved)
setBankDraft(saved.map((b) => ({ ...b })))
toast.success("Bank details updated")
} catch (err) {
toast.error("Could not save bank details", errorMessage(err))
} finally {
setSavingBank(false)
}
}
async function handleUpload(file: File) {
if (!uploadTypeId) { toast.error("Select a document type first"); return }
try {
@@ -214,27 +290,44 @@ export default function EmployeeDetailPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
<p className="text-base text-muted-foreground">
{employee.employeeCode}
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
<Badge variant="outline" className={cn("h-6 px-2.5 text-xs", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
<span className="font-mono">{employee.employeeCode}</span>
<span aria-hidden="true">·</span>
{employee.userId ? (
<span className="ml-2 inline-flex items-center gap-1 text-primary"><Link2 className="size-3.5" />Linked to a system user</span>
<span className="inline-flex items-center gap-1 text-primary"><Link2 className="size-3.5" />Linked to a system user</span>
) : (
<span className="ml-2 text-muted-foreground">No system login</span>
<span>No system login</span>
)}
</p>
</div>
<Badge variant="outline" className={cn("h-7 px-3 text-sm", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
{(employee.status === "Active" || employee.status === "Suspended") && (
<div className="flex items-center gap-2">
<span className={cn("text-sm font-medium", employee.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
{employee.status === "Active" ? "Active" : "Inactive"}
</span>
<Switch
checked={employee.status === "Active"}
onCheckedChange={handleToggleStatus}
disabled={statusPending}
aria-label={employee.status === "Active" ? "Deactivate employee" : "Activate employee"}
/>
</div>
)}
</div>
<div className="flex gap-1 rounded-xl bg-muted p-1 w-fit">
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn("rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{t}
</button>
@@ -242,132 +335,231 @@ export default function EmployeeDetailPage() {
</div>
{tab === "Overview" && (
<FieldGroup className="max-w-2xl">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field><FieldLabel>Full name</FieldLabel><Input value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
<Field><FieldLabel>Email</FieldLabel><Input type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
<Field><FieldLabel>NIC</FieldLabel><Input value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
<Field><FieldLabel>Personal mobile</FieldLabel><Input value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
<Field>
<FieldLabel>Department</FieldLabel>
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Designation</FieldLabel>
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Employment type</FieldLabel>
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Work shift</FieldLabel>
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<SelectTrigger disabled={!isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
)}
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div>
</FieldGroup>
<div className="flex flex-col gap-4">
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<User className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
<p className="text-sm text-muted-foreground">Contact and identity information.</p>
</div>
</div>
<FieldGroup>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field><FieldLabel>Full name</FieldLabel><Input disabled={!isEditing} value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
<Field><FieldLabel>NIC</FieldLabel><Input disabled={!isEditing} value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
<Field><FieldLabel>Personal mobile</FieldLabel><Input disabled={!isEditing} value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
</div>
</FieldGroup>
</section>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Briefcase className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Employment details</h2>
<p className="text-sm text-muted-foreground">Where and how this employee works.</p>
</div>
</div>
<FieldGroup>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<SelectTrigger disabled={!isEditing || !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>Department</FieldLabel>
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Designation</FieldLabel>
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Employment type</FieldLabel>
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Work shift</FieldLabel>
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
</div>
</FieldGroup>
</section>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<ShieldCheck className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Statutory details</h2>
<p className="text-sm text-muted-foreground">Government registration numbers.</p>
</div>
</div>
<FieldGroup>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 sm:max-w-md">
<Field><FieldLabel>EPF number</FieldLabel><Input disabled={!isEditing} value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input disabled={!isEditing} value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div>
</FieldGroup>
</section>
{isEditing && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div>
)}
</div>
)}
{tab === "Bank Details" && (
<div className="flex flex-col gap-4">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Bank</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
<TableHead className="h-12 px-3 text-sm">Primary</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bankDetails.map((b, i) => (
<TableRow key={b.employeeBankDetailId ?? i}>
<TableCell className="px-3 py-3.5">{b.bankName}</TableCell>
<TableCell className="px-3 py-3.5">{b.branchName}</TableCell>
<TableCell className="px-3 py-3.5">{b.accountNumber}</TableCell>
<TableCell className="px-3 py-3.5">{b.isPrimary ? "Yes" : "—"}</TableCell>
</TableRow>
))}
{bankDetails.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No bank details on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field><FieldLabel>Bank name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.bankName ?? ""} onChange={(e) => updateBankRow(0, { bankName: e.target.value })} /></Field>
<Field><FieldLabel>Branch name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.branchName ?? ""} onChange={(e) => updateBankRow(0, { branchName: e.target.value })} /></Field>
<Field><FieldLabel>Account number</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountNumber ?? ""} onChange={(e) => updateBankRow(0, { accountNumber: e.target.value })} /></Field>
<Field><FieldLabel>Account holder name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountHolderName ?? ""} onChange={(e) => updateBankRow(0, { accountHolderName: e.target.value })} /></Field>
<Field><FieldLabel>SWIFT code (optional)</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.swiftCode ?? ""} onChange={(e) => updateBankRow(0, { swiftCode: e.target.value || null })} /></Field>
</div>
</section>
{isEditing && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" />
Cancel
</Button>
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
</div>
)}
</div>
)}
{tab === "Documents" && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
<SelectTrigger className="w-56"><SelectValue placeholder="Document type" /></SelectTrigger>
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
<label className={cn("inline-flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted")}>
<Upload className="size-4" />
Upload
<input type="file" className="hidden" accept=".pdf,.jpg,.jpeg,.png,.docx" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }} />
</label>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">File</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Uploaded</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Download</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId}>
<TableCell className="px-3 py-3.5">{d.originalFileName}</TableCell>
<TableCell className="px-3 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{d.status}</TableCell>
<TableCell className="px-3 py-3.5">
<a href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">Download</a>
</TableCell>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Upload className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Upload a document</h2>
<p className="text-sm text-muted-foreground">NIC, contracts, certificates — PDF, image, or Word file.</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="w-full sm:max-w-64">
<FieldLabel htmlFor="doc-upload-type">Document type</FieldLabel>
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
<SelectTrigger id="doc-upload-type"><SelectValue placeholder="Select a type" /></SelectTrigger>
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<label
className={cn(
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)}
title={uploadTypeId ? undefined : "Select a document type first"}
>
<Upload className="size-4" />
Choose file to upload
<input
type="file"
className="hidden"
disabled={!uploadTypeId}
accept=".pdf,.jpg,.jpeg,.png,.docx"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }}
/>
</label>
</div>
</section>
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
))}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
))}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
)}
{tab === "Salary & Loans" && (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
@@ -405,32 +597,83 @@ export default function EmployeeDetailPage() {
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">Effective to</TableHead>
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId}>
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-3 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-3 py-3.5">{s.status}</TableCell>
</TableRow>
))}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
{currentStructure && (
<div className="rounded-xl border p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">Current breakdown</h3>
<span className="text-xs text-muted-foreground">Effective {new Date(currentStructure.effectiveFrom).toLocaleDateString()}</span>
</div>
<div className="mt-3 flex flex-col gap-1.5 text-sm">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Basic salary</span>
<span className="text-right font-medium text-foreground">{money(currentStructure.basicSalary)}</span>
</div>
{earningLines.map((l) => (
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{l.salaryComponentName ?? "Allowance"}</span>
<span className="text-right font-medium text-foreground">+{money(l.amount)}</span>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
<span className="font-semibold text-foreground">Gross pay</span>
<span className="text-right font-semibold text-foreground">{money(grossPay)}</span>
</div>
{deductionLines.length > 0 && (
<>
<div className="mt-3 flex flex-col gap-1.5 text-sm">
{deductionLines.map((l) => (
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{l.salaryComponentName ?? "Deduction"}</span>
<span className="text-right font-medium text-destructive">−{money(l.amount)}</span>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
<span className="font-semibold text-foreground">Net pay</span>
<span className="text-right font-semibold text-foreground">{money(netPay)}</span>
</div>
</>
)}
</TableBody>
</Table>
<p className="mt-3 text-xs text-muted-foreground">
OT, Late/No-Pay, loan installments, EPF/ETF, and tax are computed at payroll time and aren&apos;t part of this structure.
</p>
</div>
)}
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow>
))}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</section>
<section className="flex flex-col gap-4">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
@@ -467,31 +710,35 @@ export default function EmployeeDetailPage() {
</DialogContent>
</Dialog>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Principal</TableHead>
<TableHead className="h-12 px-3 text-sm">Outstanding</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId}>
<TableCell className="px-3 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-3 py-3.5">{l.status}</TableCell>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
))}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow>
))}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</section>
</div>
)}
@@ -0,0 +1,353 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Briefcase, KeyRound, Link2, Save, User } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { rolesApi } from "@/lib/api/roles"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Branch, CreateEmployeeRequest, Department, Designation, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { Role } from "@/types/rbac"
import { UserTypeOption } from "@/types/users"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const emptyForm: CreateEmployeeRequest = {
employeeCode: "",
fullName: "",
email: "",
hireDate: new Date().toISOString().slice(0, 10),
departmentId: 0,
designationId: 0,
employmentTypeId: 0,
branchId: 0,
workShiftId: 0,
}
export default function NewEmployeePage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
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[]>([])
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Advisory cross-link suggestion: does a System User already exist with this email?
const [userMatch, setUserMatch] = useState<UserMatch | null>(null)
const [checkingEmail, setCheckingEmail] = useState(false)
// Optional: create a login account for this employee in the same step.
const [grantLogin, setGrantLogin] = useState(false)
const [loginUsername, setLoginUsername] = useState("")
const [loginRoleId, setLoginRoleId] = useState("")
const [loginUserTypeId, setLoginUserTypeId] = useState("")
const [roles, setRoles] = useState<Role[]>([])
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
// Admins have access to every branch regardless of which one their staff record names —
// don't make the creator pick one, just quietly default it (the Employee row still needs
// a home branch on file; it just never gates what an Admin login can see or do).
const selectedLoginRole = roles.find((r) => String(r.roleId) === loginRoleId)
const loginIsAdmin = grantLogin && !userMatch && selectedLoginRole?.code?.toUpperCase() === "ADMIN"
useEffect(() => {
if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
}, [lockedBranchId])
useEffect(() => {
if (loginIsAdmin && !form.branchId) {
const fallbackBranchId = lockedBranchId ?? branches[0]?.branchId
if (fallbackBranchId) setForm((f) => ({ ...f, branchId: fallbackBranchId }))
}
}, [loginIsAdmin, lockedBranchId, branches, form.branchId])
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([]))
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items)).catch(() => setRoles([]))
usersApi.userTypes().then(setUserTypes).catch(() => setUserTypes([]))
}, [branchingEnabled])
async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return }
setCheckingEmail(true)
try {
const { match } = await employeesApi.emailLookup(email.trim())
setUserMatch(match)
} catch {
setUserMatch(null)
} finally {
setCheckingEmail(false)
}
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
if (!form.fullName.trim()) nextErrors.fullName = "Full name is required"
if (!form.hireDate) nextErrors.hireDate = "Hire date is required"
if (!form.departmentId) nextErrors.departmentId = "Department is required"
if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !loginIsAdmin && !form.branchId) nextErrors.branchId = "Branch is required"
if (grantLogin && !userMatch) {
if (!form.email?.trim()) nextErrors.email = "Email is required to grant login access"
if (!loginUsername.trim()) nextErrors.loginUsername = "Username is required"
if (!loginRoleId) nextErrors.loginRoleId = "Role is required"
if (!loginUserTypeId) nextErrors.loginUserTypeId = "User type is required"
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created = await employeesApi.create({
...form,
email: form.email || null,
linkUserId: userMatch ? userMatch.userId : null,
})
// Grant login access in the same step — no separate "Add User" flow needed.
if (grantLogin && !userMatch) {
try {
await usersApi.create({
username: loginUsername.trim(),
fullName: form.fullName.trim(),
email: form.email!.trim(),
roleId: Number(loginRoleId),
userTypeId: loginUserTypeId,
mobileNumber: form.personalMobile || undefined,
nic: form.nic || undefined,
linkEmployeeId: created.data.employeeId,
})
toast.success("Employee created", "Login credentials have been emailed to the employee.")
} catch (err) {
toast.error("Employee created, but login account failed", errorMessage(err))
}
} else {
toast.success("Employee created")
}
router.push(`/dashboard/hrm/employees/${created.data.employeeId}`)
} catch (err) {
toast.error("Could not create employee", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex w-full flex-col gap-6 pb-24">
<div>
<h1 className="text-2xl font-bold text-foreground">New employee</h1>
<p className="text-base text-muted-foreground">Add a staff record — grant login access at the end if this employee needs one.</p>
</div>
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<User className="size-4.5" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
<p className="text-sm text-muted-foreground">Basic identity and contact information.</p>
</div>
</div>
<FieldGroup>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.employeeCode}>
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
</Field>
<Field data-invalid={!!errors.hireDate}>
<FieldLabel htmlFor="e-hire">Hire date</FieldLabel>
<Input id="e-hire" type="date" value={form.hireDate} onChange={(e) => setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} />
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
</Field>
</div>
<Field data-invalid={!!errors.fullName}>
<FieldLabel htmlFor="e-name">Full name</FieldLabel>
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} />
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="e-email">Email {grantLogin ? "" : "(optional)"}</FieldLabel>
<Input
id="e-email"
type="email"
value={form.email ?? ""}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
onBlur={(e) => checkEmail(e.target.value)}
aria-invalid={!!errors.email}
/>
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing system user…</p>}
{userMatch && (
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<Link2 className="size-4 shrink-0 text-primary" />
<span>
System user <strong>{userMatch.username}</strong> ({userMatch.displayName}) matches this email — it will be linked to this employee.
</span>
</div>
)}
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
</Field>
</FieldGroup>
</section>
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Briefcase className="size-4.5" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Employment</h2>
<p className="text-sm text-muted-foreground">Where this employee sits in the org.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-dept" className="h-10 w-full"><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="e-desig">Designation</FieldLabel>
<Select value={form.designationId ? String(form.designationId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, designationId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-desig" className="h-10 w-full"><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="e-etype">Employment type</FieldLabel>
<Select value={form.employmentTypeId ? String(form.employmentTypeId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-etype" className="h-10 w-full"><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="e-shift">Work shift</FieldLabel>
<Select value={form.workShiftId ? String(form.workShiftId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-shift" className="h-10 w-full"><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 && loginIsAdmin && (
<Field className="sm:col-span-2">
<FieldLabel>Branch</FieldLabel>
<div className="flex h-9 items-center rounded-lg border border-primary/30 bg-primary/5 px-3 text-sm text-muted-foreground">
Not needed — the Admin role grants access to every branch.
</div>
</Field>
)}
{branchingEnabled && !loginIsAdmin && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select value={form.branchId ? String(form.branchId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-branch" className="h-10 w-full" 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>
</section>
{!userMatch && (
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors", grantLogin ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
<KeyRound className="size-4.5" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Grant login access</h2>
<p className="text-sm text-muted-foreground">Optional — creates a system user account for this employee automatically.</p>
</div>
</div>
<Switch checked={grantLogin} onCheckedChange={(v) => setGrantLogin(v === true)} aria-label="Grant login access" />
</label>
{grantLogin && (
<FieldGroup className="border-t pt-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.loginUsername}>
<FieldLabel htmlFor="e-login-username">Username</FieldLabel>
<Input
id="e-login-username"
value={loginUsername}
onChange={(e) => setLoginUsername(e.target.value)}
aria-invalid={!!errors.loginUsername}
/>
<FieldError errors={[errors.loginUsername ? { message: errors.loginUsername } : undefined]} />
</Field>
<Field data-invalid={!!errors.loginRoleId}>
<FieldLabel htmlFor="e-login-role">Role</FieldLabel>
<Select value={loginRoleId || undefined} onValueChange={(v) => setLoginRoleId(v ?? "")}>
<SelectTrigger id="e-login-role" className="h-10 w-full" aria-invalid={!!errors.loginRoleId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{roles.map((r) => <SelectItem key={r.roleId} value={String(r.roleId)}>{r.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.loginRoleId ? { message: errors.loginRoleId } : undefined]} />
</Field>
<Field data-invalid={!!errors.loginUserTypeId} className="sm:col-span-2">
<FieldLabel htmlFor="e-login-usertype">User type</FieldLabel>
<Select value={loginUserTypeId || undefined} onValueChange={(v) => setLoginUserTypeId(v ?? "")}>
<SelectTrigger id="e-login-usertype" className="h-10 w-full" aria-invalid={!!errors.loginUserTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{userTypes.map((t) => <SelectItem key={t.userTypeId} value={t.userTypeId}>{t.code ?? t.description ?? t.userTypeId}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.loginUserTypeId ? { message: errors.loginUserTypeId } : undefined]} />
</Field>
</div>
<p className="text-xs text-muted-foreground">A password will be auto-generated and emailed to the employee.</p>
</FieldGroup>
)}
</section>
)}
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
<Save className="size-4" />
{submitting ? "Creating…" : "Create employee"}
</Button>
</div>
</div>
)
}
@@ -2,137 +2,85 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon, X } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Branch, CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { EmployeeListItem } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25
const emptyForm: CreateEmployeeRequest = {
employeeCode: "",
fullName: "",
email: "",
hireDate: new Date().toISOString().slice(0, 10),
departmentId: 0,
designationId: 0,
employmentTypeId: 0,
branchId: 0,
workShiftId: 0,
}
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
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 { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [q, setQ] = useState("")
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
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[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Advisory cross-link suggestion: does a System User already exist with this email?
const [userMatch, setUserMatch] = useState<UserMatch | null>(null)
const [checkingEmail, setCheckingEmail] = useState(false)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
function load() {
employeesApi
.list({ page, pageSize: PAGE_SIZE, q: q || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.list({ page, pageSize, q: debouncedSearch || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, q, activeBranchId, branchingEnabled])
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled])
// Switching branch changes what "page 1" even means — stay on a page past the
// new filtered result set otherwise.
useEffect(() => setPage(1), [activeBranchId])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
const t = setTimeout(() => {
setPage(1)
setDebouncedSearch(search.trim())
}, 300)
return () => clearTimeout(t)
}, [search])
async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return }
setCheckingEmail(true)
try {
const { match } = await employeesApi.emailLookup(email.trim())
setUserMatch(match)
} catch {
setUserMatch(null)
} finally {
setCheckingEmail(false)
}
function loadStats() {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([
employeesApi.list({ page: 1, pageSize: 1, branchId }),
employeesApi.list({ page: 1, pageSize: 1, status: "Active", branchId }),
employeesApi.list({ page: 1, pageSize: 1, status: "Terminated", branchId }),
])
.then(([totalRes, activeRes, terminatedRes]) =>
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, terminated: terminatedRes.pagination.totalItems })
)
.catch(() => setStats(null))
}
useEffect(loadStats, [activeBranchId, branchingEnabled])
function resetForm() {
setForm(emptyForm)
setUserMatch(null)
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
if (!form.fullName.trim()) nextErrors.fullName = "Full name is required"
if (!form.hireDate) nextErrors.hireDate = "Hire date is required"
if (!form.departmentId) nextErrors.departmentId = "Department is required"
if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
// Permanent delete — blocked by the backend (409) if the employee has payroll history.
async function handleDelete(e: EmployeeListItem) {
setActionPendingId(e.employeeId)
try {
await employeesApi.create({
...form,
email: form.email || null,
linkUserId: userMatch ? userMatch.userId : null,
})
toast.success("Employee created")
setOpen(false)
resetForm()
await employeesApi.remove(e.employeeId)
toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
load()
loadStats()
} catch (err) {
toast.error("Could not create employee", errorMessage(err))
toast.error("Could not delete employee", errorMessage(err))
} finally {
setSubmitting(false)
setActionPendingId(null)
}
}
@@ -140,176 +88,264 @@ export default function EmployeesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
{pagination && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pagination.totalItems}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
</div>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) resetForm()
else if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
}}
>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
<DialogContent className="sm:max-w-lg">
<DialogHeader className="items-center text-center">
<DialogTitle>New Employee</DialogTitle>
<DialogDescription>Not every employee needs a login — a system user account is optional and separate.</DialogDescription>
</DialogHeader>
<FieldGroup>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.employeeCode}>
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
</Field>
<Field data-invalid={!!errors.hireDate}>
<FieldLabel htmlFor="e-hire">Hire date</FieldLabel>
<Input id="e-hire" type="date" value={form.hireDate} onChange={(e) => setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} />
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
</Field>
</div>
<Field data-invalid={!!errors.fullName}>
<FieldLabel htmlFor="e-name">Full name</FieldLabel>
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} />
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="e-email">Email (optional)</FieldLabel>
<Input
id="e-email"
type="email"
value={form.email ?? ""}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
onBlur={(e) => checkEmail(e.target.value)}
/>
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing system user…</p>}
{userMatch && (
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<Link2 className="size-4 shrink-0 text-primary" />
<span>
System user <strong>{userMatch.username}</strong> ({userMatch.displayName}) matches this email — it will be linked to this employee.
</span>
</div>
)}
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
<Select value={String(form.departmentId)} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
<SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
</Field>
<Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="e-desig">Designation</FieldLabel>
<Select value={String(form.designationId)} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
<SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
</Field>
<Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
<Select value={String(form.employmentTypeId)} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
<SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
</Field>
<Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
<Select value={String(form.workShiftId)} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
<SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select value={String(form.branchId)} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
) : (
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{lockedBranchName ?? "No branch assigned — contact an admin"}
</div>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
</div>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div>
</DialogContent>
</Dialog>
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Employee
</Link>
</div>
<Input placeholder="Search by name or employee code…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value) }} className="max-w-sm" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
)}
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or employee code…"
className="h-10 pl-9 pr-9"
aria-label="Search employees"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
{!error && employees === null && (
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && employees !== null && employees.length === 0 && (
{!error && employees === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!error && employees !== null && employees.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No employees match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different name or code.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && employees !== null && employees.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<UsersIcon className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No employees yet.</p>
<div>
<p className="text-base font-medium text-foreground">No employees yet</p>
<p className="text-sm text-muted-foreground">Add your first employee record to get started.</p>
</div>
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Employee
</Link>
</div>
)}
{!error && employees !== null && employees.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">Department</TableHead>
<TableHead className="h-12 px-3 text-sm">Designation</TableHead>
<TableHead className="h-12 px-3 text-sm">Login</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId}>
<TableCell className="px-3 py-3.5 font-medium">{e.employeeCode}</TableCell>
<TableCell className="px-3 py-3.5">{e.fullName}</TableCell>
<TableCell className="px-3 py-3.5">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
{e.hasUserLink ? (
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2.5 text-sm text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-sm text-muted-foreground">No login</span>
)}
</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", e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{e.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{e.fullName}
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
{e.hasUserLink ? (
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-xs text-muted-foreground">No login</span>
)}
</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{e.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDelete(e)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
{pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
)
}

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