diff --git a/Backend/ERPCore/Controllers/GeneralLedgerController.cs b/Backend/ERPCore/Controllers/GeneralLedgerController.cs index 837a942..ac80666 100644 --- a/Backend/ERPCore/Controllers/GeneralLedgerController.cs +++ b/Backend/ERPCore/Controllers/GeneralLedgerController.cs @@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase [HttpPut("{**path}")] public Task Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct); + [HttpPatch("{**path}")] + public Task Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct); + + [HttpDelete("{**path}")] + public Task Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct); + private async Task ForwardAsync(HttpMethod method, string path, CancellationToken ct) { - var body = method == HttpMethod.Get ? null : Request.Body; + var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body; var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct); return new ContentResult { diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs index 50c947e..b462a60 100644 --- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase CancellationToken ct) => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); + // Totals for the list's stat cards: same filters as List, but over every match rather than one page. + [HttpGet("totals")] + [ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)] + public async Task> Totals( + [FromQuery] string? q, + [FromQuery] SalesInvoiceStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + [FromQuery] int? branchId, + [FromQuery] DateOnly? from, + [FromQuery] DateOnly? to, + [FromQuery] int? createdBy, + [FromQuery] bool? paid, + CancellationToken ct) + => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); + [HttpGet("{salesInvoiceId:int}")] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs index 171157a..1ac53c7 100644 --- a/Backend/ERPCore/Controllers/UsersController.cs +++ b/Backend/ERPCore/Controllers/UsersController.cs @@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase public async Task> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) => Ok(await _users.UpdateRoleAsync(userId, request, ct)); - /// Revokes/restores standing inside ERPCore only — see . + /// Edits DisplayName/Email/MobileNumber on both backends — see . + [HttpPut("{userId:int}")] + [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct) + => Ok(await _users.UpdateAsync(userId, request, ct)); + + /// Revokes/restores standing in both ERPCore and AuthHex — see . [HttpPatch("{userId:int}/status")] [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct) => Ok(await _users.SetStatusAsync(userId, request.Status, ct)); + /// Emails the user a 15-minute password-reset link — see . + [HttpPost("{userId:int}/send-reset-link")] + [ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SendResetLink(int userId, CancellationToken ct) + => Ok(await _users.SendResetLinkAsync(userId, ct)); + /// Device/login history ERPCore captures itself — not AuthHex's "currently active sessions" /// (which only ever reports the caller's own). See . [HttpGet("{userId:int}/login-devices")] diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs index 750eb67..0282282 100644 --- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser public string? Email { get; set; } } +/// Admin-only profile edit — any field left null is left unchanged upstream. +public sealed class AdminUpdateUserRequest +{ + public string? FullName { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? MobileNumber { get; set; } +} + public sealed class RegisterRequest { /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted. diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 77cd30d..8b5ac94 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto( DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines); +/// Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page. +public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount); + public sealed record SalesInvoiceSummaryDto( int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType, diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs index 40fe0e2..597fe83 100644 --- a/Backend/ERPCore/Dtos/Users/UserDtos.cs +++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs @@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } } +/// +/// Edits an existing user's identity fields in both backends: the local shadow row +/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex +/// account itself via the admin-only AdminUpdateUser function. A user created before +/// AuthHex linking (AuthUserId null) only updates locally. +/// +public sealed class UpdateManagedUserRequest +{ + [Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty; + [Required, EmailAddress] public string Email { get; set; } = string.Empty; + public string? MobileNumber { get; set; } +} + +public sealed record SendResetLinkResponse(DateTime? ExpiresAt); + /// AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough). public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description); diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs index f9fd12d..b22d624 100644 --- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs +++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs @@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str /// Bin/location resource (docs/11 §2.5). public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); +/// Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`: +/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits. +public static class PhoneRules +{ + public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$"; + public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567)."; +} + public sealed class CreateWarehouseRequest { [Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(300)] public string? Address { get; set; } - [StringLength(30)] public string? Phone { get; set; } + [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; } [Required] public int BranchId { get; set; } } @@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest [Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(300)] public string? Address { get; set; } - [StringLength(30)] public string? Phone { get; set; } + [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; } [Required] public int BranchId { get; set; } } diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs index 5a4789e..df53201 100644 --- a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs @@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct); public Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct) - { - var adminApiKey = _config["AuthHex:AdminApiKey"]; - if (string.IsNullOrEmpty(adminApiKey)) - throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500); - - return CallAsync( + => CallAsync( "user", "deleteUser", new { email, mobileNumber, nic, userName = username }, null, ct, - extraHeaders: new Dictionary { ["X-Admin-Api-Key"] = adminApiKey }); + extraHeaders: AdminHeaders()); + + public Task AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct) + => CallAsync( + "user", "AdminUpdateUser", + new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber }, + null, ct, + extraHeaders: AdminHeaders()); + + public Task AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct) + => CallAsync( + "user", "AdminSetUserStatus", + new { userId, isActive }, + null, ct, + extraHeaders: AdminHeaders()); + + /// Shared guard for every admin-only AuthHex function — throws up front rather + /// than leaving a misconfigured deployment to fail opaquely on the network call. + private Dictionary AdminHeaders() + { + var adminApiKey = _config["AuthHex:AdminApiKey"]; + if (string.IsNullOrEmpty(adminApiKey)) + throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500); + + return new Dictionary { ["X-Admin-Api-Key"] = adminApiKey }; } // ---- RecoveryManager ------------------------------------------------ diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs index db2e1fa..23ce724 100644 --- a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs +++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs @@ -38,6 +38,15 @@ public interface IAuthHexClient /// Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct); + /// Admin-only: updates another user's identity fields (not self-service — + /// only ever touches the caller's own account). + /// Gated by AuthHex:AdminApiKey. + Task AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct); + + /// Admin-only: activates/deactivates another user's AuthHex identity and, when + /// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey. + Task AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct); + // RecoveryManager (POST /api/recovery) Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); diff --git a/Backend/ERPCore/Services/Hrm/BranchService.cs b/Backend/ERPCore/Services/Hrm/BranchService.cs index 9c7cfc8..72ec93b 100644 --- a/Backend/ERPCore/Services/Hrm/BranchService.cs +++ b/Backend/ERPCore/Services/Hrm/BranchService.cs @@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService var branch = await _branches.GetByIdAsync(branchId, ct) ?? throw new NotFoundException($"Branch {branchId} was not found."); + if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode) + throw new DomainException( + ErrorCodes.DefaultBranchProtected, + $"'{branch.Name}' is the deployment's default branch and cannot be deactivated.", + 409); + branch.Status = status; branch.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); @@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService var branch = await _branches.GetByIdAsync(branchId, ct) ?? throw new NotFoundException($"Branch {branchId} was not found."); + if (branch.Code == Branch.DefaultCode) + throw new DomainException( + ErrorCodes.DefaultBranchProtected, + $"'{branch.Name}' is the deployment's default branch and cannot be deleted.", + 409); + _branches.Remove(branch); try { diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs index 8bbca39..0bfe92d 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -8,6 +8,8 @@ namespace ERPCore.Services.Interfaces; public interface ISalesInvoiceService { Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); + /// Count and amount totals over every invoice matching the same filters as . + Task TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs index 9ebad23..a43a112 100644 --- a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs +++ b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs @@ -17,16 +17,27 @@ public interface IUserManagementService Task CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default); Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); + /// + /// Edits DisplayName/Email (and MobileNumber upstream) on both the local shadow row and, + /// when linked, the AuthHex identity itself (via the admin-only AdminUpdateUser function). + /// + Task UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default); + /// /// 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. + /// BranchAccessService and the rest of ERPCore gate on — and, when the user has a + /// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus + /// function (deactivating additionally invalidates their live AuthHex sessions), so a + /// user shown Inactive here can no longer log in at all, not just inside ERPCore. /// Task SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); + /// + /// Emails the user a time-limited (15 min) password-reset link via AuthHex's existing + /// self-service forgot-password flow, triggered on their behalf by an admin. + /// + Task SendResetLinkAsync(int userId, CancellationToken ct = default); + /// AuthHex UserType options for the create-user form's select. Task> ListUserTypesAsync(CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/PriceAdjustmentService.cs b/Backend/ERPCore/Services/PriceAdjustmentService.cs index 1984221..7837e8d 100644 --- a/Backend/ERPCore/Services/PriceAdjustmentService.cs +++ b/Backend/ERPCore/Services/PriceAdjustmentService.cs @@ -25,6 +25,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService public const string ApprovalNavCode = "products.price-adjustments"; private readonly IRepository _adjustments; + private readonly IRepository _lines; private readonly IRepository _items; private readonly IRepository _history; private readonly IRepository _users; @@ -36,11 +37,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService private readonly IUnitOfWork _uow; public PriceAdjustmentService( - IRepository adjustments, IRepository items, IRepository history, - IRepository users, IRepository config, IApprovalService approvals, - INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + IRepository adjustments, IRepository lines, IRepository items, + IRepository history, IRepository users, IRepository config, + IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers, + ICurrentUser currentUser, IUnitOfWork uow) { _adjustments = adjustments; + _lines = lines; _items = items; _history = history; _users = users; @@ -92,10 +95,14 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService var ids = request.Lines.Select(l => l.ItemId).ToList(); var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); - var pendingItemIds = await _adjustments.Query().AsNoTracking() - .Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval) - .SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo })) - .Where(x => ids.Contains(x.ItemId)) + // Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a + // plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that + // Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this + // predates — 2026-10-16 MySQL-port regression). + var pendingItemIds = await _lines.Query().AsNoTracking() + .Where(l => ids.Contains(l.ItemId) + && (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval)) + .Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo }) .ToListAsync(ct); var lines = new List(); diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs index 34d0c5c..16257ab 100644 --- a/Backend/ERPCore/Services/RoleService.cs +++ b/Backend/ERPCore/Services/RoleService.cs @@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService private readonly IRepository _rolePermissions; private readonly IRepository _permissions; private readonly IRepository _employees; + private readonly IRepository _users; private readonly ICurrentUser _currentUser; private readonly IAuthHexClient _authHex; private readonly IBranchConfigService _branchConfig; @@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService public RoleService( IRepository roles, IRepository rolePermissions, IRepository permissions, - IRepository employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow) + IRepository employees, IRepository users, ICurrentUser currentUser, IAuthHexClient authHex, + IBranchConfigService branchConfig, IUnitOfWork uow) { _roles = roles; _rolePermissions = rolePermissions; _permissions = permissions; _employees = employees; + _users = users; _currentUser = currentUser; _authHex = authHex; _branchConfig = branchConfig; @@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService var role = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + if (status == EntityStatus.Inactive) + await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct); + role.Status = status; role.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); @@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService var role = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct); + try { await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); @@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService await _uow.SaveChangesAsync(ct); } + /// + /// A role still assigned to at least one local (ERPCore shadow) user cannot be + /// deactivated or deleted — that user would be left pointing at a role they can no + /// longer effectively hold. Reassign them to a different role first. + /// + private async Task EnsureNotAssignedToAnyUserAsync(int roleId, string action, CancellationToken ct) + { + var assignedUserCount = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId == roleId, ct); + if (assignedUserCount > 0) + throw new DomainException( + ErrorCodes.RoleInUse, + $"This role is assigned to {assignedUserCount} user(s) and cannot be {action}. Reassign them to a different role first.", + 409); + } + public async Task GetPermissionsAsync(int roleId, CancellationToken ct = default) { _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); diff --git a/Backend/ERPCore/Services/SalesConfigService.cs b/Backend/ERPCore/Services/SalesConfigService.cs index cd906bf..002ad03 100644 --- a/Backend/ERPCore/Services/SalesConfigService.cs +++ b/Backend/ERPCore/Services/SalesConfigService.cs @@ -41,13 +41,37 @@ public sealed class SalesConfigService : ISalesConfigService public async Task> GetAsync(CancellationToken ct = default) { - var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse) - .FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct) - ?? throw new NotFoundException("Sales configuration has not been seeded."); + var config = await LoadAsync(ct); + + // "Allow cash customer" can be on with no customer linked — the flag was set outside + // UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer + // because there's no id to offer, so link (or create) CUST-CASH here instead. + if (config.AllowCashCustomer && config.CashCustomerId is null) + { + var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct); + if (tracked is not null && tracked.CashCustomerId is null) + { + tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + // A concurrent save got there first — whatever it stored is re-read below. + } + } + config = await LoadAsync(ct); + } return new ETagged(Map(config), config.RowVersion); } + private async Task LoadAsync(CancellationToken ct) => + await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse) + .FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct) + ?? throw new NotFoundException("Sales configuration has not been seeded."); + public async Task> UpdateAsync( UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) { diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 23c73b7..067ca89 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -53,10 +53,44 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) { - IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); - if (!string.IsNullOrWhiteSpace(query.Q)) + var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + + return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) + { + var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); + // Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule. + var today = DateTime.UtcNow.Date; + + var totals = await filtered + .GroupBy(_ => 1) + .Select(g => new + { + Count = g.Count(), + Net = g.Sum(x => x.GrandTotal), + Paid = g.Sum(x => x.PaidAmount), + Balance = g.Sum(x => x.BalanceAmount > 0 ? x.BalanceAmount : 0), + Unpaid = g.Count(x => x.BalanceAmount > 0), + Overdue = g.Count(x => x.BalanceAmount > 0 && x.InvoiceDate < today), + }) + .FirstOrDefaultAsync(ct); + + return totals is null + ? new SalesInvoiceListTotalsDto(0, 0m, 0m, 0m, 0, 0) + : new SalesInvoiceListTotalsDto(totals.Count, totals.Net, totals.Paid, totals.Balance, totals.Unpaid, totals.Overdue); + } + + /// The list's filters, shared with so the totals always cover exactly the listed rows. + private static IQueryable Filter(IQueryable q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid) + { + if (!string.IsNullOrWhiteSpace(search)) { - var term = query.Q.Trim(); + var term = search.Trim(); q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%")); } if (status is not null) q = q.Where(x => x.Status == status); @@ -72,11 +106,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService // paid=true: nothing left to pay; paid=false: a balance is still due. if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); if (paid == false) q = q.Where(x => x.BalanceAmount > 0); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); - - return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + return q; } public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default) diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs index 048dd46..fd605b1 100644 --- a/Backend/ERPCore/Services/UserManagementService.cs +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService return Map(user); } + public async Task UpdateAsync(int userId, UpdateManagedUserRequest request, 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."); + + var displayName = request.DisplayName.Trim(); + var email = request.Email.Trim(); + + // Source of truth first — if AuthHex rejects this (e.g. email already taken by + // another identity), the local row must not drift out of sync with it. + if (user.AuthUserId is Guid authUserId) + { + await _authHex.AdminUpdateUserAsync(authUserId, new AdminUpdateUserRequest + { + FullName = displayName, + Email = email, + MobileNumber = request.MobileNumber + }, ct); + } + + user.DisplayName = displayName; + user.Email = email; + await _uow.SaveChangesAsync(ct); + + return Map(user); + } + public async Task 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."); + // The local flag alone is cosmetic — without this, a user shown "Inactive" here + // could still log in directly against AuthHex. + if (user.AuthUserId is Guid authUserId) + await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct); + user.Status = status; await _uow.SaveChangesAsync(ct); return Map(user); } + public async Task SendResetLinkAsync(int userId, CancellationToken ct = default) + { + var user = await _users.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User {userId} was not found."); + if (string.IsNullOrWhiteSpace(user.Email)) + throw new DomainException(ErrorCodes.Validation, "This user has no email address on file.", 422); + + var result = await _authHex.ForgotPasswordAsync(new ForgotPasswordRequest + { + Identifier = user.Email, + UseResetLink = true + }, ct); + + return new SendResetLinkResponse(result.ExpiresAt); + } + public async Task> ListUserTypesAsync(CancellationToken ct = default) { var userTypes = await _authHex.ListUserTypesAsync(ct); diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 57297c0..3c29e09 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -56,6 +56,7 @@ public static class ErrorCodes public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY"; + public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED"; // Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4) public const string TemplateInUse = "TEMPLATE_IN_USE"; diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index fb17e25..a16f2ba 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -9,7 +9,8 @@ "DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com" }, "AuthHex": { - "BaseUrl": "http://localhost:5011" + "BaseUrl": "http://localhost:5011", + "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" }, "GeneralLedgerService": { "BaseUrl": "https://localhost:7024/api/v1/" diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index 890c884..e6047ff 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -3,7 +3,8 @@ "SigningKey": "" }, "AuthHex": { - "BaseUrl": "http://localhost:6703" + "BaseUrl": "http://localhost:6703", + "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" }, "GeneralLedgerService": { "BaseUrl": "http://localhost:6704/api/v1/", diff --git a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx index 7dd938c..79d2080 100644 --- a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx +++ b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react" +import { ArrowLeft, CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } from "lucide-react" import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger" import { errorMessage } from "@/lib/error-map" @@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format" import { cn } from "@/lib/utils" import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" type AccountTypeFilter = CashBankAccountType | "Both" @@ -27,23 +31,25 @@ export default function BankAccountsPage() { const [search, setSearch] = useState("") const [accountType, setAccountType] = useState("Both") - // GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table - // before) — re-fetches whenever the filter changes, unlike the plain client-side search below. - useEffect(() => { - let cancelled = false + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState("") + const [editError, setEditError] = useState(null) + const [editSubmitting, setEditSubmitting] = useState(false) + const [statusPendingId, setStatusPendingId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + function load() { Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) .then(([banks, gl]) => { - if (cancelled) return setAccounts(banks) setGlAccounts(gl.items) }) - .catch((err) => { - if (!cancelled) setError(errorMessage(err)) - }) - return () => { - cancelled = true - } - }, [accountType]) + .catch((err) => setError(errorMessage(err))) + } + + // GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table + // before) — re-fetches whenever the filter changes, unlike the plain client-side search below. + useEffect(load, [accountType]) const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts]) @@ -64,6 +70,57 @@ export default function BankAccountsPage() { }) }, [accounts, search, glAccountsById]) + function openEdit(a: CashAndBankAccountDto) { + setEditTarget(a) + setEditName(a.accountName) + setEditError(null) + } + + async function handleRename() { + if (!editTarget) return + if (!editName.trim()) { + setEditError("Account name is required") + return + } + setEditSubmitting(true) + try { + await bankAccountsApi.updateCash(editTarget.accountId, editName.trim()) + toast.success("Cash account renamed") + setEditTarget(null) + load() + } catch (err) { + setEditError(errorMessage(err)) + } finally { + setEditSubmitting(false) + } + } + + async function toggleStatus(a: CashAndBankAccountDto) { + setStatusPendingId(a.accountId) + try { + await bankAccountsApi.setCashStatus(a.accountId, !a.isActive) + toast.success(a.isActive ? "Cash account deactivated" : "Cash account activated") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setStatusPendingId(null) + } + } + + async function handleDelete(a: CashAndBankAccountDto) { + setDeletingId(a.accountId) + try { + await bankAccountsApi.deleteCash(a.accountId) + toast.success("Cash account deleted") + load() + } catch (err) { + toast.error("Could not delete cash account", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + return (
@@ -167,19 +224,61 @@ export default function BankAccountsPage() { {a.currencyCode} {formatReportDate(a.createdAt)} - - - } - > - - - - Editing isn't available yet — the General Ledger service has no update endpoint for - {a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts. - - + {a.accountType === CashBankAccountType.Cash ? ( +
+ + + + + } + > + + + handleDelete(a)} + /> + +
+ ) : ( + + + } + > + + + + Editing isn't available yet — the General Ledger service has no update endpoint for bank accounts. + + + )}
) @@ -187,6 +286,34 @@ export default function BankAccountsPage() { )} + + { if (!v) setEditTarget(null) }}> + + + Rename cash account + + {editTarget?.isDefault + ? "This is the default cash account — only its name can be changed." + : "Update this cash account's display name."} + + + + + Account name + setEditName(e.target.value)} aria-invalid={!!editError} /> + + + +
+ + +
+
+
) } diff --git a/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx b/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx index bbb0877..b87775e 100644 --- a/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx +++ b/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx @@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -/** What the edit dialog is editing — a category (no GL code) or a title. */ -type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null - export default function ExpenseSetupPage() { const [categories, setCategories] = useState(null) const [error, setError] = useState(null) const [selectedId, setSelectedId] = useState(null) const [addTitleOpen, setAddTitleOpen] = useState(false) - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) function load() { expenseSetupApi @@ -45,10 +42,10 @@ export default function ExpenseSetupPage() { const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null return ( -
+
-

Expense Setup

-

+

Expense Setup

+

The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.

@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() { {categories && (
-
-
- - -
+
{selected.code === "SALARIES" && ( @@ -106,7 +100,36 @@ export default function ExpenseSetupPage() { {selected.titles.length === 0 ? (

No titles yet — add the first one.

) : ( -
+ <> + {/* Phones: one card per title — tap anywhere on it to edit. */} +
    + {selected.titles.map((t) => ( +
  • + +
  • + ))} +
+
@@ -128,7 +151,7 @@ export default function ExpenseSetupPage() { {t.glExpenseAccountCode} {t.status === "Active" ? "Active" : } - @@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
+ )}
)} @@ -179,7 +203,7 @@ function InactiveBadge() { ) } -function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) { +function EditDialog({ editing, onClose, onSaved }: { editing: ExpenseTitle | null; onClose: () => void; onSaved: () => void }) { const [name, setName] = useState("") const [glCode, setGlCode] = useState("") const [active, setActive] = useState(true) @@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: const [saving, setSaving] = useState(false) // Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs. - const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null - const [resetFor, setResetFor] = useState(null) + const key = editing?.expenseTitleId ?? null + const [resetFor, setResetFor] = useState(null) if (key !== resetFor) { setResetFor(key) if (editing) { - setName(editing.row.name) - setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "") - setActive(editing.row.status === "Active") + setName(editing.name) + setGlCode(editing.glExpenseAccountCode) + setActive(editing.status === "Active") setError(null) } } if (!editing) return null - const isSystem = editing.row.isSystem + const isSystem = editing.isSystem async function submit() { if (!editing) return if (!name.trim()) return setError("Enter a name") - if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code") + if (!glCode.trim()) return setError("Enter the GL expense account code") const status: EntityStatus = active ? "Active" : "Inactive" setSaving(true) try { - if (editing.kind === "category") { - await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status }) - } else { - await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status }) - } + await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status }) toast.success("Saved", name.trim()) onSaved() onClose() @@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: !open && onClose()}> - Edit {editing.kind === "category" ? "category" : "title"} + Edit title @@ -236,13 +256,11 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: setName(e.target.value)} /> {isSystem && Built-in names can't be changed.} - {editing.kind === "title" && ( - - GL expense account code - setGlCode(e.target.value)} /> - Used for expenses posted from now on; already-posted expenses keep their account. - - )} + + GL expense account code + setGlCode(e.target.value)} /> + Used for expenses posted from now on; already-posted expenses keep their account. + Active — offered on new expenses @@ -250,10 +268,10 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: {error &&

{error}

}
- - diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx index 9bea575..1d5e5da 100644 --- a/Frontend/erp-system/app/dashboard/customers/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -236,11 +236,13 @@ export default function CustomersPage() { } /> - + New customer Its code is generated from the name — everything else can be filled in later from its profile. + {/* Only the fields scroll — title and Create/Cancel stay visible on a phone. */} +
Name @@ -263,10 +265,10 @@ export default function CustomersPage() { -
+
Phone (optional) - setPhone(e.target.value)} placeholder="0771234567" /> + setPhone(e.target.value)} placeholder="0771234567" /> Email (optional) @@ -281,7 +283,7 @@ export default function CustomersPage() { Address line 2 (optional) setAddressLine2(e.target.value)} placeholder="Wellawatte" /> -
+
City (optional) setCity(e.target.value)} placeholder="Colombo" /> @@ -291,7 +293,7 @@ export default function CustomersPage() { setCountry(e.target.value)} placeholder="Sri Lanka" />
-
+
Credit limit (LKR) setCreditLimit(e.target.value)} /> @@ -318,11 +320,12 @@ export default function CustomersPage() { )} -
-
+
+ -
diff --git a/Frontend/erp-system/app/dashboard/hrm/page.tsx b/Frontend/erp-system/app/dashboard/hrm/page.tsx new file mode 100644 index 0000000..d26b5f4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/page.tsx @@ -0,0 +1,93 @@ +"use client" + +import Link from "next/link" +import { Banknote, CalendarCheck, CalendarClock, IdCard, SlidersHorizontal, Users } from "lucide-react" + +import { cn } from "@/lib/utils" +import { useAuth } from "@/components/auth/AuthProvider" +import { buttonVariants } from "@/components/ui/button" + +// Same entries (and permission codes) as the HRM group in the sidebar. +const sections = [ + { + title: "Employees", + description: "Employee records, departments and designations.", + href: "/dashboard/hrm/employees", + code: "hrm.employees", + icon: Users, + }, + { + title: "Attendance", + description: "Daily attendance and time records.", + href: "/dashboard/hrm/attendance", + code: "hrm.attendance", + icon: CalendarCheck, + }, + { + title: "Leave", + description: "Leave requests, approvals and balances.", + href: "/dashboard/hrm/leave", + code: "hrm.leave", + icon: CalendarClock, + }, + { + title: "Payroll", + description: "Salary runs and payslips.", + href: "/dashboard/hrm/payroll", + code: "hrm.payroll", + icon: Banknote, + }, + { + title: "Settings", + description: "HRM masters and configuration.", + href: "/dashboard/hrm/settings", + code: "hrm.settings", + icon: SlidersHorizontal, + }, +] + +/** HRM overview — what the breadcrumb's "HRM" opens; one card per HRM section the user can see. */ +export default function HrmHubPage() { + const { navCodes, isAdmin, loading } = useAuth() + // Admins see everything; otherwise the same nav codes that decide the sidebar's HRM entries. + const visible = loading ? [] : sections.filter((s) => isAdmin || navCodes.includes(s.code)) + + return ( +
+
+
+ + HRM +
+

HRM

+

Employees, attendance, leave and payroll in one place.

+
+ + {!loading && visible.length === 0 && ( +

+ You don't have access to any HRM sections. +

+ )} + +
+ {visible.map((section) => { + const Icon = section.icon + return ( + +
+ +
+

{section.title}

+

{section.description}

+
Open
+ + ) + })} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 33625f4..547da52 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -34,6 +34,7 @@ import BarChart from "@/components/ui/bar-chart" import LineChart from "@/components/ui/line-chart" const TREND_DAYS = 14 +const MOBILE_TREND_DAYS = 5 /** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */ const CHART_MIN_SLOTS = 5 @@ -112,6 +113,16 @@ export default function DashboardPage() { // warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep // the bars at the start. Skipped on mobile, where the bars should stretch to fill the card. const isMobile = useIsMobile() + // Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width. + const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS + const shownTrend = useMemo( + () => ({ + labels: movementTrend.labels.slice(-trendDays), + inData: movementTrend.inData.slice(-trendDays), + outData: movementTrend.outData.slice(-trendDays), + }), + [movementTrend, trendDays] + ) const valuationRows = stats?.stockValuationByWarehouse ?? [] const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length) @@ -130,26 +141,28 @@ export default function DashboardPage() {
)} -
+ {/* Three across until there is room for six — at six-up the cards get too narrow and switch to the + compact stacked layout, so they would not match the stat cards on every other screen. */} +
{loaded ? ( <> - - + + - - + + - - + + - - + + - - + + - - + + ) : ( @@ -199,23 +212,23 @@ export default function DashboardPage() {

- Stock Movement Trend (last {TREND_DAYS} days) + Stock Movement Trend (last {trendDays} days)

{loaded ? ( - movementTrend.inData.some((v) => v > 0) || movementTrend.outData.some((v) => v > 0) ? ( + shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
) : ( -

No stock movement in the last {TREND_DAYS} days.

+

No stock movement in the last {trendDays} days.

) ) : ( !error && diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 8de035d..66aa90a 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -230,7 +230,7 @@ function NewPurchaseReturnContent() { <>
- + items={grnOptions} value={grnId} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index ad38b2d..c501021 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -63,6 +63,8 @@ import { ITEM_IMAGE_MAX_COUNT, itemImageProblem, } from "@/components/products/ItemImagesPanel" +import { AddUomDialog } from "@/components/products/AddUomDialog" +import { AddBrandDialog } from "@/components/products/AddBrandDialog" function skuSegment(text: string, maxLen: number): string { const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") @@ -118,6 +120,8 @@ export default function NewItemPage() { const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) /** Defaults to the first UOM once loaded; null only means none exist yet. */ const [baseUomId, setBaseUomId] = useState(null) + const [addUomOpen, setAddUomOpen] = useState(false) + const [addBrandOpen, setAddBrandOpen] = useState(false) // Kept as a raw string: an empty box means "no content size", which is different from 0. const [contentQty, setContentQty] = useState("") const [contentUnit, setContentUnit] = useState(null) @@ -216,7 +220,11 @@ export default function NewItemPage() { setBaseUomId(uoms.items[0]?.uomId ?? null) setWarehouses(wh.items) }) - .catch((err) => setLoadError(errorMessage(err))) + .catch((err) => { + const message = errorMessage(err) + setLoadError(message) + toast.error("Could not load the form", message) + }) // Optional extra: a failure here just means only the default periods are offered. warrantyPeriodsApi .list({ status: "Active" }) @@ -350,6 +358,7 @@ export default function NewItemPage() { const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths) if (parsed.error !== undefined) { setCustomPeriodError(parsed.error) + toast.error("Invalid warranty period", parsed.error) return } setItemOnlyWarrantyMonths(parsed.months) @@ -375,24 +384,30 @@ export default function NewItemPage() { * in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own * content size — the same path as a measurable Item Variant. Name defaults to "Size". */ + /** Inline message under the custom-variant box, mirrored as a toast. */ + function failCustom(message: string) { + setCustomError(message) + toast.error("Could not add custom variant", message) + } + function createCustomVariant() { const name = customName.trim() || (customIsSize ? "Size" : "") const invalid = validateItemTypeName(name).name if (invalid) { - setCustomError(invalid) + failCustom(invalid) return } if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) { - setCustomError(`"${name}" already exists — tick it above instead.`) + failCustom(`"${name}" already exists — tick it above instead.`) return } if (customIsSize && !measurementUnit) { - setCustomError("Pick a Measurement unit at the top first — size values are entered in it.") + failCustom("Pick a Measurement unit at the top first — size values are entered in it.") return } // Only one dimension may supply the content size, or a variant would have two. if (customIsSize && measurableCheckedCount > 0) { - setCustomError("Only one size variant can be used at a time — untick the other one first.") + failCustom("Only one size variant can be used at a time — untick the other one first.") return } const local: ItemType = { @@ -413,6 +428,10 @@ export default function NewItemPage() { * duplicate pair never reaches state, so nothing downstream has to defend against one. */ function addValue(itemTypeId: number) { + const failValue = (message: string) => { + setValueErrors((prev) => ({ ...prev, [itemTypeId]: message })) + toast.error("Could not add value", message) + } const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false if (!measurable) { @@ -435,11 +454,11 @@ export default function NewItemPage() { const qty = Number(raw) if (!raw || Number.isNaN(qty) || qty <= 0) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" })) + failValue("Enter a size greater than 0") return } if (!unit) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" })) + failValue("Select a unit") return } @@ -448,7 +467,7 @@ export default function NewItemPage() { const key = measureKey(qty, unit) const existing = valuesByCategory[itemTypeId] ?? [] if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" })) + failValue("That size is already in the list") return } @@ -708,13 +727,44 @@ export default function NewItemPage() { const loading = !categories || !brands || !itemTypes || !config + /** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */ + function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) { + const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}` + const invalid = kind === "flat" && !!priceErrors[variantKey] + return ( + { + const value = e.target.value + setPricesByKey((prev) => ({ ...prev, [stateKey]: value })) + if (kind === "flat") + setPriceErrors((prev) => { + if (!prev[variantKey]) return prev + const next = { ...prev } + delete next[variantKey] + return next + }) + }} + placeholder="0.00" + aria-label={`${kind === "flat" ? "Flat" : kind === "min" ? "Min" : "Max"} price`} + aria-invalid={invalid} + className={cn("h-10 text-base", className)} + /> + ) + } + + /** Mobile-first section shell: tighter padding on phones, roomier from `sm` up. */ + const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5" + return ( -
-
-
-

New Item

-

Category, subcategory, brand, and item variants (FR-MD-01).

-
+
+
+

New Item

+

Category, subcategory, brand, and item variants (FR-MD-01).

{loadError && ( @@ -726,17 +776,19 @@ export default function NewItemPage() { {!loading && baseUomId === null && (
No unit of measure exists yet. Items need a base UOM —{" "} - - create one first - + .
)} {!loading && ( <> +
+

Basic details

-
+
+
+ + value={brandId} + onValueChange={setBrandId} + items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + > + + + + + {(brands ?? []).map((b) => ( + + {b.name} + + ))} + + + +
+
+ )} +
+
+ +
+

Units & stock

+
+
+ +
- value={brandId} - onValueChange={setBrandId} - items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + value={baseUomId} + onValueChange={setBaseUomId} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} > - - + + - {(brands ?? []).map((b) => ( - - {b.name} + {uoms.map((u) => ( + + {u.name} ))} +
- )} -
- -
- { - setLowStockAt(e.target.value) - clearErrors("lowStockAt", "reorderQty") - }} - placeholder="Alert when stock ≤" - aria-label="Alert when stock is at or below" - aria-invalid={!!errors.lowStockAt} - className="h-12! text-base" - /> - { - setReorderQty(e.target.value) - clearErrors("reorderQty") - }} - placeholder="Reorder qty" - aria-label="Reorder quantity" - aria-invalid={!!errors.reorderQty} - className="h-12! text-base" - /> -
- -

- {lowStockAt.trim() - ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.` - : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} -

-
-
- - - value={warehouseId} - onValueChange={setWarehouseId} - items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} - > - - - - - {warehouses.map((w) => ( - - {w.code} — {w.name} - - ))} - - -

Where the low-stock alert watches stock. None picked = every warehouse.

-
-
- - - value={baseUomId} - onValueChange={setBaseUomId} - items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} - > - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - -
{/* The unit size variants are measured in. Picked once here; every size value added below (custom size variant or a measurement Item Variant) starts in this unit. */} @@ -1026,11 +1042,88 @@ export default function NewItemPage() {

+
+ +
+
+

Low stock alert

+

Optional — get notified when stock runs low.

+
+
+
+ + {/* Stays side by side even on phones: two short numbers read as one rule. */} +
+ { + setLowStockAt(e.target.value) + clearErrors("lowStockAt", "reorderQty") + }} + placeholder="Alert at" + aria-label="Alert when stock is at or below" + aria-invalid={!!errors.lowStockAt} + className="h-12! text-base" + /> + { + setReorderQty(e.target.value) + clearErrors("reorderQty") + }} + placeholder="Reorder qty" + aria-label="Reorder quantity" + aria-invalid={!!errors.reorderQty} + className="h-12! text-base" + /> +
+ +

+ {lowStockAt.trim() + ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.` + : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} +

+
+
+ + + value={warehouseId} + onValueChange={setWarehouseId} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > + + + + + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))} + + +

Where the low-stock alert watches stock. None picked = every warehouse.

+
+
+
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( -
+
{config?.sellingPricesEnabled && ( -
+

Variant selling prices

@@ -1054,8 +1147,8 @@ export default function NewItemPage() { {priceMode === "fixed" && ( -

-
+
+
{ const raw = defaultFlatPrice.trim() if (!raw) return @@ -1096,7 +1190,7 @@ export default function NewItemPage() { )} {config?.warrantyPeriodsEnabled && ( -
+

Warranty

@@ -1140,7 +1234,7 @@ export default function NewItemPage() { value: m, }))} > - + @@ -1253,7 +1347,7 @@ export default function NewItemPage() { )} {config?.itemImagesEnabled && ( -

+

Images (optional) @@ -1275,7 +1369,7 @@ export default function NewItemPage() { type="button" onClick={() => removePendingImage(p.url)} aria-label={`Remove ${p.file.name}`} - className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive" + className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive" > @@ -1309,7 +1403,7 @@ export default function NewItemPage() { {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && ( -
+

Item variants

@@ -1329,7 +1423,7 @@ export default function NewItemPage() { )}

-
+
{(itemTypes ?? []).map((t) => { const checked = checkedItemTypeIds.includes(t.itemTypeId) // Only one dimension may supply the content size, or a variant would have two. @@ -1339,7 +1433,7 @@ export default function NewItemPage() { key={t.itemTypeId} title={blocked ? "Only one measurement dimension can be used at a time." : undefined} className={cn( - "flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", + "flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", blocked ? "opacity-50" : "hover:bg-muted/50" )} > @@ -1362,7 +1456,7 @@ export default function NewItemPage() { })} {config?.customVariantsEnabled && !customOpen && ( - @@ -1414,11 +1508,11 @@ export default function NewItemPage() { /> {measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"} -
- -
@@ -1490,9 +1584,15 @@ export default function NewItemPage() { )} -
) : ( @@ -1509,9 +1609,15 @@ export default function NewItemPage() { placeholder={t.name} className="h-11 text-base" /> -
)} @@ -1525,7 +1631,7 @@ export default function NewItemPage() { + + + ))} + + +
+ )}
)} @@ -1657,16 +1769,40 @@ export default function NewItemPage() {
{submitError}
)} -
- + {/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */} +
+ {variants.length > 0 && ( +

+ {variants.length} variant{variants.length === 1 ? "" : "s"} will be created +

+ )} + Cancel -
)} + + { + setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }]) + setBaseUomId(uom.uomId) + setSubmitError(null) + }} + /> + { + setBrands((prev) => [...(prev ?? []), brand]) + setBrandId(brand.brandId) + }} + />
) } diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 1a64d67..25335bc 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" import { Checkbox } from "@/components/ui/checkbox" import { Field, FieldLabel } from "@/components/ui/field" import { FilterBar } from "@/components/ui/filter-bar" @@ -187,7 +188,9 @@ export default function ItemsPage() { if (!append) setSelected(new Set()) }) .catch((err) => { - if (!cancelled) setError(errorMessage(err)) + if (cancelled) return + setError(errorMessage(err)) + toast.error("Could not load items", errorMessage(err)) }) .finally(() => { if (!cancelled) setLoadingMore(false) @@ -344,19 +347,30 @@ export default function ItemsPage() {

Items

Stock value, last purchase and sales activity at a glance — click a row for its full profile.

-
- + {/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and + emphasised on desktop; the secondary actions share a row beneath it on mobile. */} +
+ + + New Item + + - Category/Brand/UOM + + Masters + Category/Brand/UOM + - - - New Item -
@@ -475,7 +489,7 @@ export default function ItemsPage() {

{hasFilters ? "No items match your search/filter." : "No items yet."}

{!hasFilters && ( - + New Item diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 6fc438a..88eceff 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -23,14 +23,15 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges" +import { SerialRanges } from "@/components/receiving/SerialRanges" import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" -/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */ +/** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */ function LineTrackingInfo({ line }: { line: GrnLine }) { const serials = line.serials ?? [] - if (!line.batchNo && serials.length === 0) return null + if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null return (
{line.batchNo && ( @@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) { {line.batchExpiryDate && <> · expires {line.batchExpiryDate}}

)} - {serials.length > 0 && ( -
- - {serials.length} serial number{serials.length === 1 ? "" : "s"} - -
    - {serials.map((s) => ( -
  • {s.serialNo}
  • - ))} -
-
- )} + s.serialNo)} /> + w.warrantyNo)} />
) } @@ -112,7 +103,13 @@ export default function GrnDetailPage() { useEffect(() => { if (!Number.isFinite(grnId)) return - grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err))) + grnsApi + .get(grnId) + .then(setGrn) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load GRN", errorMessage(err)) + }) Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()]) .then(([it, uo]) => { setItems(it.items) @@ -258,11 +255,6 @@ export default function GrnDetailPage() {

{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}

{item?.warranty === "Warranty" && Warranty}
- {line.warrantyNumbers.length > 0 && ( -

- {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured -

- )}
@@ -378,14 +370,6 @@ export default function GrnDetailPage() { {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} {item?.warranty === "Warranty" && Warranty}
- {line.warrantyNumbers.length > 0 && ( -

`${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")} - > - {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured -

- )} {baseUomLabel(items, uoms, line.itemId)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 724e46c..1d1579a 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -289,7 +289,10 @@ export default function NewGrnPage() { setVendors(ve.items) setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived")) }) - .catch((err) => setLoadError(errorMessage(err))) + .catch((err) => { + setLoadError(errorMessage(err)) + toast.error("Could not load the GRN form", errorMessage(err)) + }) }, []) useEffect(() => { @@ -318,6 +321,27 @@ export default function NewGrnPage() { const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null const visibleWarehouses = warehouses ?? [] + // Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen. + function failHeader(message: string) { + setHeaderError(message) + toast.error("Check the GRN details", message) + } + + function failScan(message: string) { + setScanError(message) + toast.error("Scan failed", message) + } + + function failSubmit(message: string) { + setSubmitError(message) + toast.error("Could not create GRN", message) + } + + function failWarranty(lineKey: string, message: string) { + setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message })) + toast.error("Could not fill warranty numbers", message) + } + function switchMode(next: Mode) { setMode(next) setPoId(null) @@ -342,7 +366,7 @@ export default function NewGrnPage() { const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId) const openLines = po.lines.filter((l) => l.qtyReceived < l.qty) if (openLines.length === 0) { - setHeaderError("This purchase order has no open (unreceived) lines.") + failHeader("This purchase order has no open (unreceived) lines.") setLines([]) return } @@ -369,7 +393,7 @@ export default function NewGrnPage() { }) ) } catch (err) { - setHeaderError(errorMessage(err)) + failHeader(errorMessage(err)) } finally { setPoLoading(false) } @@ -403,9 +427,9 @@ export default function NewGrnPage() { addItemLine(result.itemId, scannedBatchNo) } catch (err) { if (err instanceof ApiError && err.status === 404) { - setScanError(`No item or lot found for "${code}"`) + failScan(`No item or lot found for "${code}"`) } else { - setScanError(errorMessage(err)) + failScan(errorMessage(err)) } } finally { setScanning(false) @@ -526,7 +550,7 @@ export default function NewGrnPage() { function replaceWarrantyNumbers(lineKey: string, units: number) { const range = warrantyRangeInputs[lineKey] if (!range?.from.trim() || !range?.adding.trim()) { - setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" })) + failWarranty(lineKey, "Enter both a from number and an adding number") return } // "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1 @@ -536,14 +560,11 @@ export default function NewGrnPage() { const to = applyAddingNumber(from, range.adding) const seq = buildWarrantyRange(from, to) if (!seq) { - setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" })) + failWarranty(lineKey, "The computed number must share From's prefix and not come before it") return } if (seq.length > units) { - setWarrantyAddErrors((prev) => ({ - ...prev, - [lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`, - })) + failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`) return } setWarrantyAddErrors((prev) => { @@ -636,23 +657,23 @@ export default function NewGrnPage() { poId: mode === "po" ? poId : null, }) if (!header.success) { - setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.") + failHeader(header.error.issues[0]?.message ?? "Check the header fields.") return } if (mode === "direct" && !vendorId) { - setHeaderError("Select a vendor for a direct receipt.") + failHeader("Select a vendor for a direct receipt.") return } if (mode === "po" && !poId) { - setHeaderError("Select a purchase order.") + failHeader("Select a purchase order.") return } if (branchingEnabled && !effectiveBranchId) { - setHeaderError("Select a branch for this GRN.") + failHeader("Select a branch for this GRN.") return } if (lines.length === 0) { - setSubmitError("Add at least one line.") + failSubmit("Add at least one line.") return } @@ -680,6 +701,12 @@ export default function NewGrnPage() { ) setLineTab(onlyWarrantyErrors ? "warranty" : "lines") setSubmitError("Fix the highlighted lines before submitting.") + // The toast names the first problem so it's useful even when that line is scrolled out of view. + const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))] + toast.error( + "Fix the highlighted lines before submitting", + messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`, + ) return } diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx index 7b886e4..84ff24c 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils" import { useAuth } from "@/components/auth/AuthProvider" import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { Button, buttonVariants } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" import { Input } from "@/components/ui/input" import { KpiRow } from "@/components/ui/kpi-row" import { PaginationFooter } from "@/components/ui/pagination-footer" @@ -97,7 +98,10 @@ export default function GrnListPage() { setGrns(res.items) setPagination(res.pagination) }) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load GRNs", errorMessage(err)) + }) .finally(() => setShowMoreLoading(false)) } @@ -114,7 +118,7 @@ export default function GrnListPage() {
-

Goods Receipt Notes

+

Goods Received Notes

Receive goods against a purchase order, or record a direct receipt.

diff --git a/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx index 6f665a0..7408972 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx @@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf" import { cn } from "@/lib/utils" +import { useClientPagination } from "@/hooks/use-client-pagination" import { maskAccountNumber } from "@/types/bank-deposits" import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales" @@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { StatCard } from "@/components/ui/stat-card" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) ) } +/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */ +function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { + return ( +
+ {label} + {children} +
+ ) +} + +/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */ +const mobileCard = (total = false) => + cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold") + /** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */ function ProfitStatement({ day }: { day: DayEndReportDay }) { const gross = profitOrLoss(day.grossProfit) @@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) { * banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module. */ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) { + const depositPage = useClientPagination(deposits, date) const remaining = day.closingCashInHand ?? 0 return (
@@ -125,21 +142,15 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo -
0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border" - )} - > -
-

Remaining cash in hand

- 0 ? "text-warning" : "text-muted-foreground")} aria-hidden /> -
-

0 ? "text-warning" : "text-foreground")}> - {formatAmount(remaining)} -

-

{remaining > 0 ? "Not yet deposited" : "All cash banked"}

-
+ 0 ? "Not yet deposited" : "All cash banked"} + className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined} + iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined} + valueClassName={remaining > 0 ? "text-warning" : undefined} + />

Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go @@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo {deposits.length === 0 ? (

No bank deposits for {formatReportDate(date)}.

) : ( + <> +
    + {depositPage.pageRows.map((d) => ( +
  • +
    + {d.docNo} + {formatAmount(d.amount)} +
    +

    + {[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")} +

    +

    + Slip {d.depositSlipNo ?? "—"} · {formatReportDate(d.depositDate)} +

    +
  • + ))} +
  • + Total deposited + {formatAmount(day.cashDeposited ?? 0)} +
  • +
+
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo - {deposits.map((d) => ( + {depositPage.pageRows.map((d) => ( {d.docNo} @@ -184,6 +217,11 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
+
+ {depositPage.pagination.totalItems > 10 && ( + + )} + )}
) @@ -272,6 +310,10 @@ export default function DayEndReportPage() { const sales = day?.data?.sales ?? [] const expenses = day?.data?.expenses ?? [] const deposits = day?.data?.deposits ?? [] + // Paged in the browser (the report returns whole lists); a new date / range starts on page 1. + const salesPage = useClientPagination(sales, date) + const expensePage = useClientPagination(expenses, date) + const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`) return (
@@ -305,7 +347,7 @@ export default function DayEndReportPage() { {!s && !day?.error && (
- {Array.from({ length: 10 }).map((_, i) => )} + {Array.from({ length: 10 }).map((_, i) => )}
)} @@ -325,27 +367,15 @@ export default function DayEndReportPage() { -
= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30" - )} - > -
-

Income

- {s.income >= 0 - ? - : } -
-

= 0 ? "text-success" : "text-destructive")}> - {formatSignedAmount(s.income)} -

- {s.income < 0 ? ( -

Loss — expenses are more than sales

- ) : ( -

Sales − expenses

- )} -
+ = 0 ? TrendingUp : TrendingDown} + hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"} + className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"} + iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"} + valueClassName={s.income >= 0 ? "text-success" : "text-destructive"} + />

{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque @@ -361,7 +391,48 @@ export default function DayEndReportPage() { {sales.length === 0 ? (

No posted sales on {formatReportDate(date)}.

) : ( -
+ <> +
    + {salesPage.pageRows.map((row) => ( +
  • +
    + + {row.docType === "Invoice" ? ( + {row.docNo} + ) : ( + {row.docNo} + )} + + {new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · {row.docType} · {row.customerName} + + + + {formatAmount(row.total)} + {row.paidBy} + +
    +
    + {formatAmount(row.paid)} + {formatAmount(row.due, true)} + {formatAmount(row.cost)} + {formatAmount(row.grossProfit)} +
    +
  • + ))} +
  • +
    + Total + {formatAmount(s.totalSales)} +
    +
    + {formatAmount(s.paidAmount)} + {formatAmount(s.dueAmount)} + {formatAmount(s.costOfGoodsSold)} + {formatAmount(s.grossProfit)} +
    +
  • +
+
@@ -378,7 +449,7 @@ export default function DayEndReportPage() { - {sales.map((row) => ( + {salesPage.pageRows.map((row) => ( {new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} @@ -415,6 +486,10 @@ export default function DayEndReportPage() {
+ {salesPage.pagination.totalItems > 10 && ( + + )} + )}
@@ -423,6 +498,29 @@ export default function DayEndReportPage() { {expenses.length === 0 ? (

No posted expenses on {formatReportDate(date)}.

) : ( + <> +
    + {expensePage.pageRows.map((e) => ( +
  • +
    + + {e.docNo} + {e.categoryName} · {e.titleName} + {e.payee && Payee: {e.payee}} + + + {formatAmount(e.amount)} + Paid {formatAmount(e.paidAmount)} + +
    +
  • + ))} +
  • + Total + {formatAmount(s.expenseTotal)} +
  • +
+
@@ -435,7 +533,7 @@ export default function DayEndReportPage() { - {expenses.map((e) => ( + {expensePage.pageRows.map((e) => ( {e.docNo} @@ -456,6 +554,11 @@ export default function DayEndReportPage() {
+
+ {expensePage.pagination.totalItems > 10 && ( + + )} + )}
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {

Day-wise income

Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).

-
+
- setFrom(e.target.value)} className="w-44" /> + setFrom(e.target.value)} className="w-full sm:w-44" />
- setTo(e.target.value)} className="w-44" /> + setTo(e.target.value)} className="w-full sm:w-44" />
- @@ -493,7 +596,7 @@ export default function DayEndReportPage() { {!rangeError && range?.data && ( <> -
+
- + {/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */} +
+ +
-
+ {/* Phones: one card per day (newest first), tap to show that day above. */} +
    + {dayPage.pageRows.map((d) => { + const p = profitOrLoss(d.netProfit) + return ( +
  • + +
  • + ) + })} +
  • +
    + Total · {range.data.totals.salesCount} sales + + {profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount} + +
    +
    + {formatAmount(range.data.totals.totalSales)} + {formatAmount(range.data.totals.expenseTotal)} + {formatSignedAmount(range.data.totals.income)} + {formatAmount(range.data.totals.cashDeposited ?? 0)} + {formatAmount(range.data.totals.closingCashInHand ?? 0)} +
    +
  • +
+ +
@@ -531,7 +689,7 @@ export default function DayEndReportPage() { - {[...range.data.days].reverse().map((d) => ( + {dayPage.pageRows.map((d) => ( d.date && setDate(d.date)} title="Show this day above"> {formatReportDate(d.date)} {formatAmount(d.totalSales)} @@ -568,6 +726,9 @@ export default function DayEndReportPage() {
+ {dayPage.pagination.totalItems > 10 && ( + + )} )} diff --git a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx index 5c698f4..81b1fcb 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx @@ -2,17 +2,17 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react" +import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react" import { salesApi } from "@/lib/api/sales" import { salesConfigApi } from "@/lib/api/sales-config" -import { salesDayEndApi } from "@/lib/api/sales-day-end" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" +import { formatAmount } from "@/lib/format" import { cn } from "@/lib/utils" import { PaginationMeta } from "@/types/common" import { ManagedUser } from "@/types/users" -import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales" +import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" @@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s import { Button, buttonVariants } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" 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" - -function todayIso(): string { - return new Date().toISOString().slice(0, 10) -} type SalesDateRange = "All" | "Today" | "7d" | "30d" @@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [ { value: "Paid", label: "Fully paid" }, ] -export default function SalesDayEndPage() { +export default function ApprovedInvoicesPage() { const [cashiers, setCashiers] = useState([]) - const [cashierUserId, setCashierUserId] = useState(null) - const [businessDate, setBusinessDate] = useState(todayIso()) - const [preview, setPreview] = useState(null) - const [previewLoading, setPreviewLoading] = useState(false) - const [previewError, setPreviewError] = useState(null) - - const [report, setReport] = useState(null) - const [closing, setClosing] = useState(false) - - // Approved (Posted) sales invoices, shown as a table with its own filters (independent of the - // business date above): a cashier (null follows the cashier picked above, "All" shows every + // Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every // cashier's sales) and a date range. - const [salesCashier, setSalesCashier] = useState(null) - const effectiveSalesCashier = salesCashier ?? cashierUserId - const [salesDateRange, setSalesDateRange] = useState("Today") + const [salesCashier, setSalesCashier] = useState("All") + const effectiveSalesCashier = salesCashier + const [salesDateRange, setSalesDateRange] = useState("All") const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange) const [salesPayment, setSalesPayment] = useState("All") const [salesSearchInput, setSalesSearchInput] = useState("") @@ -135,30 +120,10 @@ export default function SalesDayEndPage() { useEffect(() => { usersApi .list({ pageSize: 200 }) - .then((res) => { - setCashiers(res.items) - setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null) - }) + .then((res) => setCashiers(res.items)) .catch(() => {}) }, []) - function loadPreview() { - if (!cashierUserId) return - setReport(null) - setPreviewError(null) - setPreviewLoading(true) - salesDayEndApi - .preview(cashierUserId, businessDate) - .then(setPreview) - .catch((err) => setPreviewError(errorMessage(err))) - .finally(() => setPreviewLoading(false)) - } - - useEffect(() => { - loadPreview() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cashierUserId, businessDate]) - // Debounced search; a new term starts again from page 1. useEffect(() => { const timeout = setTimeout(() => { @@ -170,7 +135,6 @@ export default function SalesDayEndPage() { }, [salesSearchInput]) useEffect(() => { - if (effectiveSalesCashier === null) return salesApi .listInvoices({ page: salesPage, @@ -186,6 +150,41 @@ export default function SalesDayEndPage() { .catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) })) }, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize]) + // Stat cards: the same filters as the table, totalled over every match (not one page). Keyed + // like the table so a change of dropdown — or a payment — refreshes them. + const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|") + const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null) + const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined + // If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed + // from the loaded page and labelled as such — rather than disappearing. + const pageTotals: SalesInvoiceListTotals | null = sales + ? { + count: salesPagination?.totalItems ?? sales.length, + netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0), + paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0), + balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0), + unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length, + overdueCount: sales.filter(isOverdue).length, + } + : null + const pageOnly = totals === null + const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals + + useEffect(() => { + salesApi + .invoiceTotals({ + status: "Posted", + createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, + from: salesFrom, + to: salesTo, + paid: salesPayment === "All" ? undefined : salesPayment === "Paid", + q: salesQuery || undefined, + }) + .then((data) => setTotalsResult({ key: totalsKey, data })) + // Cards are a summary — a failure blanks them (the table reports its own errors). + .catch(() => setTotalsResult({ key: totalsKey, data: null })) + }, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery]) + /** Re-read the table (after a payment); ticks are cleared since balances changed. */ function reloadSales() { setSelected(new Map()) @@ -198,12 +197,6 @@ export default function SalesDayEndPage() { setSelected(new Map()) } - function changeCashier(userId: number | null) { - setCashierUserId(userId) - setSalesCashier(null) - resetSalesView() - } - function changeSalesCashier(value: number | "All") { setSalesCashier(value) resetSalesView() @@ -224,23 +217,6 @@ export default function SalesDayEndPage() { resetSalesView() } - async function handleClose() { - if (!cashierUserId) return - setClosing(true) - try { - const result = await salesDayEndApi.close({ cashierUserId, businessDate }) - setReport(result) - toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`) - loadPreview() - } catch (err) { - toast.error("Could not close the day", errorMessage(err)) - } finally { - setClosing(false) - } - } - - const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName - const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null /** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */ @@ -262,220 +238,28 @@ export default function SalesDayEndPage() { } return ( -
-
- +
+
+
-

Day End

-

- Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger. +

Approved Invoices

+

+ Approved sales invoices — view them and take payments, one at a time or several of one customer together.

-
-
- - value={cashierUserId} onValueChange={changeCashier}> - - - - - {cashiers.map((c) => ( - - {c.displayName} - - ))} - - -
-
- - setBusinessDate(e.target.value)} - className="h-12 text-base" - /> -
-
- - {previewLoading && } - - {previewError && ( -
{previewError}
- )} - - {!previewLoading && preview && !report && ( -
- {preview.alreadyClosed ? ( -
- -
-

{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.

-

{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.

-
-
- ) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? ( -
- -
-

Still Draft — post or cancel these before closing.

-
    - {preview.draftSlipsBlockingClose.map((s) => ( -
  • - {s.slipNo} — {s.grandTotal.toFixed(2)} -
  • - ))} - {preview.draftBundleSalesBlockingClose.map((b) => ( -
  • - {b.bundleNo} — {b.grandTotal.toFixed(2)} -
  • - ))} -
-
-
- ) : ( -

- {preview.slipCount === 0 && preview.bundleCount === 0 - ? preview.invoices.invoiceCount > 0 - ? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).` - : "No sales slips or bundle sales for this cashier on this date." - : `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`} -

- )} - -
- - - - -
- -
- - -
- - - - {!preview.alreadyClosed && ( -
- -
- )} - - {preview.alreadyClosed && ( -
- -
- )} -
- )} - - {report && ( -
-
-
-

{report.docNo}

-

- {cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()} -

-
- {report.glJournalNo && ( -
- GL journal {report.glJournalNo} posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""} -
- )} -
- -
- - - - - - -
- -
- - -
- -
-

Item breakdown

- {report.itemBreakdown.length === 0 ? ( -

No lines.

- ) : ( - <> - {/* Mobile: stacked rows. */} -
- {report.itemBreakdown.map((line) => ( -
- {line.sku} — {line.name} - - {line.qty} × {line.revenue.toFixed(2)} - -
- ))} -
- - {/* Desktop: table. */} -
- - - - Item - Qty - Revenue - - - - {report.itemBreakdown.map((line) => ( - - {line.sku} — {line.name} - {line.qty} - {line.revenue.toFixed(2)} - - ))} - -
-
- - )} -
- - {report.slipNumbers.length > 0 && ( -
-

Slips included

-

{report.slipNumbers.join(", ")}

-
- )} - - {report.bundleNumbers.length > 0 && ( -
-

Bundle sales included

-

{report.bundleNumbers.join(", ")}

-
- )} - - -
- )} + c.userId === salesCashier)?.displayName ?? "Cashier"), + salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "", + paymentFilters.find((p) => p.value === salesPayment)?.label ?? "", + ].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")} + /> 0 - const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0) - const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0) - const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0) - return ( -
+

Sales

Approved sales invoices — filter by cashier, date, payment status or search.

-
+ {/* Phones: search on top, cashier full-width, then date + payment side by side. */} +
+ onSearchChange(e.target.value)} + placeholder="Search invoice or customer" + className="col-span-2 h-11 text-base sm:col-span-1 sm:h-10 sm:text-sm lg:order-last" + /> +
value={cashierFilter} onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }} items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]} > - + @@ -661,12 +449,13 @@ function SalesTable({ ))} +
value={dateRange} - onValueChange={(v) => onDateRangeChange(v ?? "Today")} + onValueChange={(v) => onDateRangeChange(v ?? "All")} items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))} > - + @@ -681,7 +470,7 @@ function SalesTable({ onValueChange={(v) => onPaymentFilterChange(v ?? "All")} items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))} > - + @@ -690,12 +479,6 @@ function SalesTable({ ))} - onSearchChange(e.target.value)} - placeholder="Search invoice or customer" - className="h-10 text-sm" - />
{error &&
{error}
} @@ -709,17 +492,18 @@ function SalesTable({ )} {selected.size > 0 && ( -
+ // Sticky on phones so "Pay selected" stays reachable while ticking rows further down. +

{selected.size} invoice{selected.size === 1 ? "" : "s"} selected for{" "} {selected.values().next().value!.customerSnapshotName} — balance{" "} {selectedBalance.toFixed(2)}

-
- - @@ -729,48 +513,66 @@ function SalesTable({ {!error && rows !== null && visible.length > 0 && ( <> - {/* Mobile: stacked rows. */} -
+ {/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so + nothing is squeezed. Tapping the details opens the invoice. */} +
{visible.map((row) => ( -
- {renderSelectBox(row)} - - {renderActions(row)} + +
+
+
+ Net + Paid + Due + {row.totals.grandTotal.toFixed(2)} + {row.totals.paidAmount.toFixed(2)} + 0 && "font-semibold text-destructive")}> + {row.totals.balanceAmount.toFixed(2)} + +
+ {renderActions(row)} +
))}
- {/* Desktop: table. */} -
- + {/* Desktop (lg+): table. Date and Paid only appear from xl, where there is room for every column. */} +
+
Invoice Customer {showCashier && Cashier} - Date + Date Due Net - Paid + Paid Remaining Status Actions @@ -790,16 +592,16 @@ function SalesTable({ {renderSelectBox(row)} {row.invoiceNo} - {row.customerSnapshotName} + {row.customerSnapshotName} {showCashier && ( {cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`} )} - {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.createdAt).toLocaleDateString()} {new Date(row.invoiceDate).toLocaleDateString()} {row.totals.grandTotal.toFixed(2)} - {row.totals.paidAmount.toFixed(2)} + {row.totals.paidAmount.toFixed(2)} )} - - {/* Invoices counts every match; the amounts are for the rows on this page. */} -
- - - - -
)} ) } -// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have -// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros -// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales. -function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) { - if (invoices.invoiceCount === 0) return null - return ( -
-
-

Sales invoices (not part of this close)

-

- Invoices post their own ledger entry when confirmed — shown here for visibility only. -

-
-
- - - - - -
-

{invoices.invoiceNumbers.join(", ")}

-
- ) -} +/** Shared card shell — tighter padding on phones, roomier from `sm` up. */ +const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5" + +/** + * Top-of-page totals for whatever the dropdowns/search currently select — across every matching + * invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available). + * `undefined` = loading. Same StatCard as the other screens' KPI rows. + */ +function InvoiceTotalsCards({ + totals, scope, pageOnly, +}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) { + if (totals === undefined) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + if (totals === null) return null -function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) { return ( -
- {label} - - {isCount ? value : value.toFixed(2)} - +
+

+ Totals for: {scope} + {pageOnly && — amounts cover this page only} +

+
+ + + + 0 ? "bg-warning/10 ring-warning/30" : undefined} + iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined} + /> + 0 && "bg-destructive/10 ring-destructive/30")} + iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined} + /> +
) } diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 3a20a9e..7651484 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { salesConfigApi } from "@/lib/api/sales-config" import { errorMessage } from "@/lib/error-map" +import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges" import { cn } from "@/lib/utils" import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils" import { Customer } from "@/types/customers" @@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
{(line.serials ?? []).length > 0 && ( -
- S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")} + // From – To runs, not one entry per unit (full list on hover). +
s.serialNo).join(", ")} + > + S/N {formatSerialRanges(toSerialRanges((line.serials ?? []).map((s) => s.serialNo)), 3)}
)} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index 9153a57..0164c58 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -154,7 +154,8 @@ export default function SalesInvoicesPage() { }, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage]) useEffect(() => { - customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) + // Includes the cash customer so its (walk-in) invoices can be filtered too. + customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([])) }, []) diff --git a/Frontend/erp-system/app/dashboard/sales/page.tsx b/Frontend/erp-system/app/dashboard/sales/page.tsx index 10deb94..2e78887 100644 --- a/Frontend/erp-system/app/dashboard/sales/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/page.tsx @@ -24,8 +24,8 @@ const sections = [ icon: PackageX, }, { - title: "Day End", - description: "Close a cashier's day and post it to the ledger.", + title: "Approved Invoices", + description: "Approved sales invoices — view them and take payments.", href: "/dashboard/sales/day-end", icon: Lock, }, diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx index 058f621..86449d1 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -3,15 +3,14 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" +import { Pencil, Plus, Power, Search, ShieldCheck, X } from "lucide-react" import { rolesApi } from "@/lib/api/roles" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { PaginationMeta } from "@/types/common" +import { EntityStatus, PaginationMeta } from "@/types/common" import { Role } from "@/types/rbac" -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" @@ -72,14 +71,15 @@ export default function RolesPage() { return () => clearTimeout(t) }, [search]) - async function handleDelete(role: Role) { + async function toggleStatus(role: Role) { + const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active" setActionPendingId(role.roleId) try { - await rolesApi.remove(role.roleId) - toast.success("Role deleted", `${role.code} has been removed.`) + await rolesApi.updateStatus(role.roleId, next) + toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`) load() } catch (err) { - toast.error("Could not delete role", errorMessage(err)) + toast.error("Could not update role status", errorMessage(err)) } finally { setActionPendingId(null) } @@ -236,29 +236,26 @@ export default function RolesPage() { > - - - } - > - - - handleDelete(r)} - /> - +
@@ -347,29 +344,26 @@ export default function RolesPage() { - - - } - > - - - handleDelete(r)} - /> - +
diff --git a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx index 1db25a1..ea8a43b 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react" +import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-react" import { auditLogsApi } from "@/lib/api/audit-logs" import { usersApi } from "@/lib/api/users" @@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit" import { ManagedUser } from "@/types/users" import { Badge } from "@/components/ui/badge" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { toast } from "@/components/ui/toast" @@ -36,6 +39,15 @@ export default function UserDetailPage() { const [statusPending, setStatusPending] = useState(false) + const [editOpen, setEditOpen] = useState(false) + const [displayName, setDisplayName] = useState("") + const [email, setEmail] = useState("") + const [mobileNumber, setMobileNumber] = useState("") + const [editErrors, setEditErrors] = useState>({}) + const [saving, setSaving] = useState(false) + + const [sendingResetLink, setSendingResetLink] = useState(false) + useEffect(() => { if (!Number.isFinite(userId)) return setLoadError(null) @@ -85,7 +97,10 @@ export default function UserDetailPage() { try { const updated = await usersApi.updateStatus(user.userId, { status: nextStatus }) setUser(updated) - toast.success(nextStatus === "Active" ? "User activated" : "User deactivated") + toast.success( + nextStatus === "Active" ? "User activated" : "User deactivated", + nextStatus === "Inactive" ? "They can no longer sign in." : undefined + ) } catch (err) { toast.error("Could not update status", errorMessage(err)) } finally { @@ -93,6 +108,54 @@ export default function UserDetailPage() { } } + function openEdit() { + if (!user) return + setDisplayName(user.displayName) + setEmail(user.email ?? "") + setMobileNumber("") + setEditErrors({}) + setEditOpen(true) + } + + async function handleSave() { + if (!user) return + const nextErrors: Record = {} + if (!displayName.trim()) nextErrors.displayName = "Display name is required" + if (!email.trim()) nextErrors.email = "Email is required" + setEditErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSaving(true) + try { + const updated = await usersApi.update(user.userId, { + displayName: displayName.trim(), + email: email.trim(), + mobileNumber: mobileNumber.trim() || null, + }) + setUser(updated) + setEditOpen(false) + toast.success("User updated", `${updated.displayName} — changes saved to both this app and the login system.`) + } catch (err) { + toast.error("Could not update user", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleSendResetLink() { + if (!user) return + setSendingResetLink(true) + try { + const result = await usersApi.sendResetLink(user.userId) + const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes" + toast.success("Reset link sent", `Emailed to ${user.email}. Expires at ${expiry}.`) + } catch (err) { + toast.error("Could not send reset link", errorMessage(err)) + } finally { + setSendingResetLink(false) + } + } + return (
@@ -112,16 +175,26 @@ export default function UserDetailPage() {

{user.displayName}

-
- - {user.status === "Active" ? "Active" : "Inactive"} - - +
+ + +
+ + {user.status === "Active" ? "Active" : "Inactive"} + + +
@@ -264,6 +337,39 @@ export default function UserDetailPage() { )}
+ + + + + Edit {user.username} + Updates both this app's record and the login system's identity. + + + + Display name + setDisplayName(e.target.value)} aria-invalid={!!editErrors.displayName} /> + + + + Email + setEmail(e.target.value)} aria-invalid={!!editErrors.email} /> + + + + Mobile number (optional) + setMobileNumber(e.target.value)} placeholder="Leave blank to keep unchanged" /> + + +
+ + +
+
+
) } diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx index e5221fb..cab838a 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" +import { KeyRound, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" @@ -45,6 +45,7 @@ export default function UsersPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [resetLinkPendingId, setResetLinkPendingId] = useState(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [showMoreLoading, setShowMoreLoading] = useState(false) @@ -105,6 +106,19 @@ export default function UsersPage() { } } + async function handleSendResetLink(u: ManagedUser) { + setResetLinkPendingId(u.userId) + try { + const result = await usersApi.sendResetLink(u.userId) + const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes" + toast.success("Reset link sent", `Emailed to ${u.email}. Expires at ${expiry}.`) + } catch (err) { + toast.error("Could not send reset link", errorMessage(err)) + } finally { + setResetLinkPendingId(null) + } + } + return (
@@ -234,14 +248,6 @@ export default function UsersPage() {
{u.roleName ?? "—"}
- - - +
-
- - - + + + diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index 721bd5c..f3d2aba 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -33,6 +33,7 @@ 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" +import { limitPhoneInput, phoneError } from "@/lib/validations/phone" /** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH" * so an empty/punctuation-only name still yields a usable base. */ @@ -153,13 +154,18 @@ export default function WarehousesPage() { async function handleCreate() { const nextErrors: Record = {} if (!name.trim()) nextErrors.name = "Warehouse name is required" + const phoneProblem = phoneError(phone) + if (phoneProblem) nextErrors.phone = phoneProblem if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required" setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create warehouse", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { - const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId }) + const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) closeCreate() load() @@ -187,13 +193,18 @@ export default function WarehousesPage() { const nextErrors: Record = {} if (!editCode.trim()) nextErrors.code = "Code is required" if (!editName.trim()) nextErrors.name = "Warehouse name is required" + const phoneProblem = phoneError(editPhone) + if (phoneProblem) nextErrors.phone = phoneProblem if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required" setEditErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not update warehouse", Object.values(nextErrors)[0]) + return + } setEditSubmitting(true) try { - const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId }) + const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId }) toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`) setEditTarget(null) load() @@ -262,9 +273,20 @@ export default function WarehousesPage() { Address (optional) setAddress(e.target.value)} placeholder="Street, city…" /> - + Phone (optional) - setPhone(e.target.value)} placeholder="+94 …" /> + { setPhone(limitPhoneInput(e.target.value)); setErrors(dropError("phone")) }} + onBlur={() => { const p = phoneError(phone); if (p) setErrors((prev) => ({ ...prev, phone: p })) }} + placeholder="0771234567 or +94771234567" + aria-invalid={!!errors.phone} + /> + {warehouseBranchingEnabled && ( @@ -556,9 +578,20 @@ export default function WarehousesPage() { Address (optional) setEditAddress(e.target.value)} placeholder="Street, city…" /> - + Phone (optional) - setEditPhone(e.target.value)} placeholder="+94 …" /> + { setEditPhone(limitPhoneInput(e.target.value)); setEditErrors(dropError("phone")) }} + onBlur={() => { const p = phoneError(editPhone); if (p) setEditErrors((prev) => ({ ...prev, phone: p })) }} + placeholder="0771234567 or +94771234567" + aria-invalid={!!editErrors.phone} + /> + {warehouseBranchingEnabled && ( diff --git a/Frontend/erp-system/app/reset-password/page.tsx b/Frontend/erp-system/app/reset-password/page.tsx new file mode 100644 index 0000000..db29011 --- /dev/null +++ b/Frontend/erp-system/app/reset-password/page.tsx @@ -0,0 +1,200 @@ +"use client" + +import { Suspense, useState } from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import { AuthShell } from "@/components/auth/AuthShell" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { authApi } from "@/lib/api/auth" +import { errorMessage } from "@/lib/error-map" +import { Check, Eye, EyeOff } from "lucide-react" + +// Destination of the 15-minute reset-link email (ERP_Auth_Service's RecoveryManagerService. +// ForgotPassword, ResetLink branch): `{FrontendUrl}/reset-password?token=...`. Distinct from +// /login/forgot/reset, which is the OTP-based self-service flow reached by typing an email in +// — this page only ever arrives via that emailed link's token. +function PasswordRequirement({ met, label }: { met: boolean; label: string }) { + return ( +
+
+ {met ? : ○} +
+ {label} +
+ ) +} + +function ResetPasswordContent() { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get("token") + + const [password, setPassword] = useState("") + const [confirm, setConfirm] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [showSuccess, setShowSuccess] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const meetsLength = password.length >= 8 && password.length <= 20 + const hasUpper = /[A-Z]/.test(password) + const hasNumber = /[0-9]/.test(password) + const hasSpecial = /[^A-Za-z0-9]/.test(password) + const passwordsMatch = password === confirm && password.length > 0 + const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch + + if (!token) { + return ( + +
+

Reset password

+

Link invalid

+

+ This reset link is missing its token. Request a new one from the sign-in page. +

+
+
+ + Request a new link + +
+
+ ) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!allValid || !token) return + setSubmitting(true) + setSubmitError(null) + try { + await authApi.resetPasswordWithToken({ resetToken: token, newPassword: password, confirmPassword: confirm }) + setShowSuccess(true) + } catch (err) { + setSubmitError(errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( + +
+

Reset password

+

Create a new password

+

Choose a strong password to secure your account.

+
+ +
+ {submitError && ( +
{submitError}
+ )} + +
+ +
+ setPassword(e.target.value)} + className="h-11 rounded-lg px-4 pr-11 text-base" + placeholder="Enter your new password" + /> + +
+
+ +
+ +
+ setConfirm(e.target.value)} + className="h-11 rounded-lg px-4 pr-11 text-base" + placeholder="Confirm your new password" + aria-invalid={!passwordsMatch && confirm.length > 0} + /> + +
+ {!passwordsMatch && confirm.length > 0 && ( +

Passwords don't match.

+ )} +
+ +
+

Password requirements:

+ + + + +
+ + + +
+ + Back to sign in + +
+ + + + + +
+
+ +
+
+ Password changed successfully + Your password has been updated. You can now sign in with your new password. +
+
+
+ +
+
+
+ ) +} + +export default function ResetPasswordPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 879ef52..8caae37 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -46,7 +46,6 @@ import { Receipt, ReceiptText, Scale, - ScanLine, ScrollText, Settings, ShieldCheck, @@ -62,12 +61,8 @@ import { type LucideIcon, } from "lucide-react" -import { approvalsApi } from "@/lib/api/approvals" import { cn } from "@/lib/utils" import { useAuth } from "@/components/auth/AuthProvider" -import { useTrackingSettings } from "@/hooks/use-tracking-settings" - -const SERIALS_HREF = "/dashboard/stock/serials" // `code` must match the seeded NavItem/SubNavItem codes in ERPCore // (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs) @@ -109,8 +104,6 @@ const navItems: { { title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag }, ], }, - // Open to everyone signed in: the inbox only lists what the user may approve and their own requests. - { title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck }, { title: "Procurement", code: "procurement", @@ -159,9 +152,6 @@ const navItems: { children: [ { title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText }, { title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch }, - // Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code. - { title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine }, - { title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock }, { title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign }, { title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight }, { title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal }, @@ -250,7 +240,6 @@ function SidebarContent({ onClose, pathname, isMobile, - approvalCount, }: { items: typeof navItems collapsed: boolean @@ -258,8 +247,6 @@ function SidebarContent({ onClose?: () => void pathname: string isMobile: boolean - /** Requests the user can approve — the badge on Approvals. */ - approvalCount: number }) { const iconOnly = !isMobile && collapsed @@ -381,11 +368,6 @@ function SidebarContent({ className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")} /> {!iconOnly && {item.title}} - {item.code === "approvals" && approvalCount > 0 && ( - - {approvalCount} - - )} )}
@@ -453,8 +435,6 @@ export function AppSidebar() { const [collapsed, setCollapsed] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) const { navCodes, loading } = useAuth() - // The Serials register is hidden while serial numbers are switched off in Product Configuration. - const { serialNumbersEnabled } = useTrackingSettings() // While /auth/me hasn't resolved yet, show nothing rather than briefly // flashing the full menu to a restricted role. Once resolved, a nav item @@ -468,16 +448,8 @@ export function AppSidebar() { // seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // anything server-side. - // "approvals" is not a permission at all — the inbox is for everyone (see its nav entry). - const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"]) + const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"]) - // Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when - // someone has most likely just approved or submitted something. - const [approvalCount, setApprovalCount] = useState(0) - useEffect(() => { - if (loading) return - approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0)) - }, [loading, pathname]) const visibleItems = loading ? [] : navItems @@ -492,7 +464,7 @@ export function AppSidebar() { children: (bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))) - )?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)), + ), })) // Close mobile menu on route change @@ -517,7 +489,6 @@ export function AppSidebar() { onCollapse={() => setCollapsed((v) => !v)} pathname={pathname} isMobile={false} - approvalCount={approvalCount} />
@@ -554,7 +525,6 @@ export function AppSidebar() { onClose={() => setMobileOpen(false)} pathname={pathname} isMobile={true} - approvalCount={approvalCount} />
diff --git a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx index 5a1e9a6..40a4d8a 100644 --- a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx +++ b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx @@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record = { vendors: "Vendors", warehouse: "Warehouses", receiving: "Receiving", - grn: "Goods Receipt Notes", + grn: "Goods Received Notes", stock: "Stock", enquiry: "Enquiry", ledger: "Ledger", @@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record = { "purchase-invoices": "Purchase Invoices", "purchase-returns": "Purchase Returns", production: "Production", + hrm: "HRM", templates: "Templates", runs: "Runs", invoices: "Sales", + "day-end": "Approved Invoices", } function labelFor(segment: string): string { @@ -51,6 +53,17 @@ function labelFor(segment: string): string { return segment.charAt(0).toUpperCase() + segment.slice(1) } +/** + * Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the + * same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.) + */ +const GROUP_LANDING: Record = { + "/dashboard/production": "/dashboard/production/runs", + "/dashboard/receiving": "/dashboard/receiving/grn", + "/dashboard/sales/bundles": "/dashboard/sales/bundles/templates", + "/dashboard/help/tickets": "/dashboard/help", +} + const linkClass = "truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" @@ -61,10 +74,10 @@ export function Breadcrumbs() { // Nothing to show above the dashboard root itself. if (segments.length <= 1) return null - const crumbs = segments.map((segment, i) => ({ - href: "/" + segments.slice(0, i + 1).join("/"), - label: labelFor(segment), - })) + const crumbs = segments.map((segment, i) => { + const path = "/" + segments.slice(0, i + 1).join("/") + return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) } + }) return ( @@ -72,7 +85,7 @@ export function Breadcrumbs() { {crumbs.map((crumb, i) => { const isLast = i === crumbs.length - 1 return ( - + {isLast ? ( {crumb.label} diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index f82e9da..e7b9a1b 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -108,10 +108,12 @@ function titleFromPath(pathname: string) { if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" + if (pathname === "/dashboard/sales/day-end") return "Approved Invoices" + if (pathname === "/dashboard/hrm") return "HRM" - if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes" - if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note" - if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note" + if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes" + if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note" + if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note" if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname] @@ -336,7 +338,8 @@ export function Header() { - + {/* Open below the pill (not over it), left edges lined up; at least 14rem wide, or the pill's width if that is wider. */} + All Branches {branches.map((b) => ( diff --git a/Frontend/erp-system/components/accounts/ExpenseForm.tsx b/Frontend/erp-system/components/accounts/ExpenseForm.tsx index 0961597..b881d37 100644 --- a/Frontend/erp-system/components/accounts/ExpenseForm.tsx +++ b/Frontend/erp-system/components/accounts/ExpenseForm.tsx @@ -425,10 +425,10 @@ export function AddTitleDialog({ {error &&

{error}

} - - diff --git a/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx b/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx index defa3ad..b737bd8 100644 --- a/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx +++ b/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx @@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex return ( - + - {isDraft ? "Post & pay" : "Pay"} expense {expense.docNo} + {isDraft ? "Post & pay" : "Pay"} expense {expense.docNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This expense is still a draft — it will be posted and paid in one step. "} Pay the full balance or change the amount to pay part of it now.{" "} @@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex

{accountsError && ( -
+
Could not load cash/bank accounts from the ledger: {accountsError}
+ - - diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx index f623de8..04dd0a2 100644 --- a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx @@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa return ( - + - {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} Balance due: {formatAmount(balance)} @@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa +

+ - - diff --git a/Frontend/erp-system/components/products/AddBrandDialog.tsx b/Frontend/erp-system/components/products/AddBrandDialog.tsx new file mode 100644 index 0000000..894cfcf --- /dev/null +++ b/Frontend/erp-system/components/products/AddBrandDialog.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" + +import { brandsApi } from "@/lib/api/brands" +import { errorMessage } from "@/lib/error-map" +import { validateBrandName } from "@/lib/validations/master-data" +import { Brand } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { FieldError } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "@/components/ui/toast" + +/** + * Creates a brand from a popup — used by the Brand tab and inline from New Item. Same + * validation and endpoint as before; the caller receives the created row. + */ +export function AddBrandDialog({ + open, + onOpenChange, + onCreated, + description = "The new brand is selected for this item and offered on other items too.", +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (brand: Brand) => void + description?: string +}) { + const [name, setName] = useState("") + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function close(next: boolean) { + if (submitting) return + if (!next) { + setName("") + setError(null) + } + onOpenChange(next) + } + + async function handleSave() { + const invalid = validateBrandName(name).name + if (invalid) { + setError(invalid) + toast.error("Could not create brand", invalid) + return + } + setSubmitting(true) + try { + const { data: brand } = await brandsApi.create({ name: name.trim() }) + toast.success("Brand created", brand.name) + onCreated(brand) + setName("") + setError(null) + onOpenChange(false) + } catch (err) { + setError(errorMessage(err)) + toast.error("Could not create brand", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Add brand + {description} + +
+ + { + setName(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="e.g. Bosch" + aria-invalid={!!error} + className="h-12 text-base" + /> + +
+ + + + +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/AddUomDialog.tsx b/Frontend/erp-system/components/products/AddUomDialog.tsx new file mode 100644 index 0000000..68549fa --- /dev/null +++ b/Frontend/erp-system/components/products/AddUomDialog.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" + +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateUomName } from "@/lib/validations/master-data" +import { Uom } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { FieldError } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "@/components/ui/toast" + +/** + * Creates a UOM without leaving the current form (e.g. New Item). Same validation and + * endpoint as the UOM tab; the caller receives the created row to add and select it. + */ +export function AddUomDialog({ + open, + onOpenChange, + onCreated, + description = "The new UOM is selected for this item and offered on other items too.", +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (uom: Uom) => void + description?: string +}) { + const [name, setName] = useState("") + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function close(next: boolean) { + if (submitting) return + if (!next) { + setName("") + setError(null) + } + onOpenChange(next) + } + + async function handleSave() { + const invalid = validateUomName(name).name + if (invalid) { + setError(invalid) + toast.error("Could not create UOM", invalid) + return + } + setSubmitting(true) + try { + const uom = await uomsApi.create({ name: name.trim() }) + toast.success("UOM created", uom.name) + onCreated(uom) + setName("") + setError(null) + onOpenChange(false) + } catch (err) { + setError(fieldErrors(err)?.name ?? errorMessage(err)) + toast.error("Could not create UOM", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Add unit of measure + {description} + +
+ + { + setName(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="e.g. Box-12" + aria-invalid={!!error} + className="h-12 text-base" + /> + +
+ + + + +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/BrandPanel.tsx b/Frontend/erp-system/components/products/BrandPanel.tsx index 7a888ce..aab02af 100644 --- a/Frontend/erp-system/components/products/BrandPanel.tsx +++ b/Frontend/erp-system/components/products/BrandPanel.tsx @@ -8,6 +8,7 @@ import { errorMessage } from "@/lib/error-map" import { validateBrandName } from "@/lib/validations/master-data" import { Brand } from "@/types/master-data" +import { AddBrandDialog } from "@/components/products/AddBrandDialog" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -25,9 +26,7 @@ export function BrandPanel() { const [brands, setBrands] = useState(null) const [error, setError] = useState(null) - const [name, setName] = useState("") - const [errors, setErrors] = useState>({}) - const [submitting, setSubmitting] = useState(false) + const [addOpen, setAddOpen] = useState(false) const [togglingId, setTogglingId] = useState(null) const [editOpen, setEditOpen] = useState(false) @@ -41,34 +40,14 @@ export function BrandPanel() { brandsApi .list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .then((res) => setBrands(res.items)) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load brands", errorMessage(err)) + }) } useEffect(load, []) - async function handleAdd() { - const nextErrors = validateBrandName(name) - setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - toast.error("Could not create brand", Object.values(nextErrors)[0]) - return - } - - setSubmitting(true) - try { - const result = await brandsApi.create({ name }) - toast.success("Brand created", result.data.name) - setName("") - setErrors({}) - load() - } catch (err) { - setErrors({ name: errorMessage(err) }) - toast.error("Could not create brand", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ async function handleToggleActive(brand: Brand) { const next = brand.status === "Active" ? "Inactive" : "Active" @@ -119,25 +98,18 @@ export function BrandPanel() { return (
-
- { - setName(e.target.value) - setErrors({}) - }} - placeholder="New brand name, e.g. Bosch" - className="h-12 flex-1 text-base" - aria-label="New brand name" - aria-invalid={!!errors.name} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - /> -
- {errors.name &&

{errors.name}

} + {error && (
{error}
diff --git a/Frontend/erp-system/components/products/UomPanel.tsx b/Frontend/erp-system/components/products/UomPanel.tsx index 84c22e3..07a9061 100644 --- a/Frontend/erp-system/components/products/UomPanel.tsx +++ b/Frontend/erp-system/components/products/UomPanel.tsx @@ -4,13 +4,12 @@ import { useEffect, useState } from "react" import { Plus, Ruler } from "lucide-react" import { uomsApi } from "@/lib/api/uoms" -import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateUomName } from "@/lib/validations/master-data" +import { errorMessage } from "@/lib/error-map" import { Uom } from "@/types/master-data" +import { AddUomDialog } from "@/components/products/AddUomDialog" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" @@ -22,16 +21,17 @@ export function UomPanel() { const [uoms, setUoms] = useState(null) const [error, setError] = useState(null) - const [name, setName] = useState("") - const [errors, setErrors] = useState>({}) - const [submitting, setSubmitting] = useState(false) + const [addOpen, setAddOpen] = useState(false) const [togglingId, setTogglingId] = useState(null) function load() { uomsApi .list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .then((res) => setUoms(res.items)) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load UOMs", errorMessage(err)) + }) } useEffect(load, []) @@ -50,51 +50,20 @@ export function UomPanel() { } } - async function handleAdd() { - const nextErrors = validateUomName(name) - setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - toast.error("Could not create UOM", Object.values(nextErrors)[0]) - return - } - - setSubmitting(true) - try { - const uom = await uomsApi.create({ name }) - toast.success("UOM created", uom.name) - setName("") - setErrors({}) - load() - } catch (err) { - const fe = fieldErrors(err) - if (fe?.name) setErrors({ name: fe.name }) - toast.error("Could not create UOM", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - return (
-
- { - setName(e.target.value) - setErrors({}) - }} - placeholder="New UOM name, e.g. Box-12" - className="h-12 flex-1 text-base" - aria-label="New UOM name" - aria-invalid={!!errors.name} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - /> -
- {errors.name &&

{errors.name}

} + {error && (
{error}
diff --git a/Frontend/erp-system/components/receiving/SerialRanges.tsx b/Frontend/erp-system/components/receiving/SerialRanges.tsx new file mode 100644 index 0000000..e9fc918 --- /dev/null +++ b/Frontend/erp-system/components/receiving/SerialRanges.tsx @@ -0,0 +1,38 @@ +"use client" + +import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges" + +/** + * Serial / warranty numbers shown as From – To ranges instead of one row per unit. A single + * run reads inline ("10 serial numbers: SN-001 – SN-010"); several runs expand behind a toggle. + */ +export function SerialRanges({ label, values }: { label: string; values: string[] }) { + const ranges = toSerialRanges(values) + if (ranges.length === 0) return null + const total = ranges.reduce((n, r) => n + r.count, 0) + const heading = `${total} ${label}${total === 1 ? "" : "s"}` + + if (ranges.length <= 2) { + return ( +

+ {heading}: {formatSerialRanges(ranges)} +

+ ) + } + + return ( +
+ + {heading} in {ranges.length} ranges: {formatSerialRanges(ranges)} + +
    + {ranges.map((r) => ( +
  • + {r.count === 1 ? r.from : `${r.from} – ${r.to}`} + ×{r.count} +
  • + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx b/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx index 1a99124..08a5be6 100644 --- a/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx +++ b/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx @@ -1,14 +1,41 @@ "use client" -import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js" +import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js" import { useTheme } from "next-themes" import { Line } from "react-chartjs-2" +import { useIsMobile } from "@/hooks/use-mobile" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { DayEndReportDay } from "@/types/sales" ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend) +/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */ +const MOBILE_POINTS = 5 + +/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the + * legend box is made taller after it measures itself — the extra height sits below its items. */ +const LEGEND_GAP_PX = 16 +const legendGap: Plugin<"line"> = { + id: "legendGap", + beforeInit(chart) { + const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined + if (!legend) return + const fit = legend.fit.bind(legend) + legend.fit = function () { + fit() + this.height += LEGEND_GAP_PX + } + }, +} + +/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */ +const LEGEND_TEXT_GAP = String.fromCharCode(0x2002) + +/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */ +const compactAmount = (v: number) => + new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v) + // Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped). const SERIES = { light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" }, @@ -17,9 +44,12 @@ const SERIES = { /** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared * index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */ -export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) { +export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) { const { resolvedTheme } = useTheme() + const isMobile = useIsMobile() const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light + const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays + const trimmed = days.length < allDays.length const labels = days.map((d) => formatReportDate(d.date)) const line = (label: string, data: number[], color: string, width: number) => ({ @@ -27,50 +57,84 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) { data, borderColor: color, backgroundColor: color, - borderWidth: width, + borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width, pointRadius: days.length > 45 ? 0 : 3, pointHoverRadius: 5, tension: 0.25, fill: false, }) + const tickFont = { size: isMobile ? 10 : 12 } + return ( -
- d.income), c.income, 2.5), - line("Sales", days.map((d) => d.totalSales), c.sales, 2), - line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2), - line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5), - ], - }} - options={{ - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: { - legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } }, - tooltip: { - callbacks: { - label: (ctx) => { - const y = ctx.parsed.y ?? 0 - if (ctx.dataset.label === "Income") - return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}` - if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}` - const p = profitOrLoss(y) - return `${p.label}: ${p.amount}` +
+ {trimmed && ( +

+ Showing the last {days.length} days — the table below lists every day. +

+ )} +
+ d.income), c.income, 2.5), + line("Sales", days.map((d) => d.totalSales), c.sales, 2), + line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2), + line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5), + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + // Legend on top (start-aligned on desktop, centred on phones) with small labels on phones. + legend: { + position: "top", + align: isMobile ? "center" : "start", + labels: { + color: c.text, + usePointStyle: true, + boxWidth: isMobile ? 6 : 8, + font: { size: isMobile ? 11 : 12 }, + // Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it. + generateLabels: (chart) => + ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })), + }, + }, + tooltip: { + callbacks: { + label: (ctx) => { + const y = ctx.parsed.y ?? 0 + if (ctx.dataset.label === "Income") + return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}` + if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}` + const p = profitOrLoss(y) + return `${p.label}: ${p.amount}` + }, }, }, }, - }, - scales: { - x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }, - y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } }, - }, - }} - /> + scales: { + x: { + grid: { display: false }, + ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont }, + }, + y: { + grid: { color: c.grid }, + ticks: { + color: c.text, + font: tickFont, + maxTicksLimit: isMobile ? 5 : 8, + callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))), + }, + }, + }, + }} + /> +
) } diff --git a/Frontend/erp-system/components/sales/LineTrackingPicker.tsx b/Frontend/erp-system/components/sales/LineTrackingPicker.tsx index 43ac207..0a9c633 100644 --- a/Frontend/erp-system/components/sales/LineTrackingPicker.tsx +++ b/Frontend/erp-system/components/sales/LineTrackingPicker.tsx @@ -147,9 +147,9 @@ function SerialPicker({ className="h-7 px-2 text-xs" disabled={toAdd.length === 0} onClick={selectAll} - title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined} + title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined} > - {toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"} + Select all
diff --git a/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx b/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx index dc43307..81e062e 100644 --- a/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx +++ b/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx @@ -63,7 +63,10 @@ interface PaymentLinesEditorProps { export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) { const paid = paymentLinesTotal(lines) const remaining = round2(balance - paid) - const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]" + // Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up + // it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them. + const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]" + const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden" function updateLine(key: number, patch: Partial) { onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l))) @@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, return (
-
+
Payment type {methodOptions && Method} Paid amount @@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
{lines.map((line, idx) => ( -
-
+
+
+ Payment type {lines.length > 1 ? idx + 1 : ""} value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}> @@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
{methodOptions && ( -
+
+ Method value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}> - + @@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
)} -
+
+ Paid amount updateLine(line.key, { amount: e.target.value })} aria-label={`Paid amount, line ${idx + 1}`} aria-invalid={!!errors[`amount-${line.key}`]} - className="text-right tabular-nums" + className="h-11 text-right text-base tabular-nums sm:h-9" />
-
+
{/* Add sits on the last line itself, so a new split appears right below it. */} {idx === lines.length - 1 && ( - @@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, ))} {/* Total is what's owed (fixed); Paid is what the lines add up to. */} -
+
Total {formatAmount(balance)} Paid amount diff --git a/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx b/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx index 8f0fabf..acda320 100644 --- a/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx @@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on return ( - + - + Pay {ordered.length} invoices — {customerName} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+ {draftCount > 0 && (

{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "} @@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on

)} -
+
- + @@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on {allocation.map(({ inv, applied }) => ( - - + + @@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on /> + + - - diff --git a/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx b/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx index 7ca923b..c68904f 100644 --- a/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx @@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid return ( - + - {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} Balance due: {formatAmount(balance)} @@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid /> +

+ - - diff --git a/Frontend/erp-system/components/settings/BranchMasterPage.tsx b/Frontend/erp-system/components/settings/BranchMasterPage.tsx index d3876c0..c61195c 100644 --- a/Frontend/erp-system/components/settings/BranchMasterPage.tsx +++ b/Frontend/erp-system/components/settings/BranchMasterPage.tsx @@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] +// Mirrors Branch.DefaultCode (backend) — the deployment's seeded branch, which +// BranchService blocks deactivating or deleting (every branch-scoped entity needs +// at least one branch to exist). +const DEFAULT_BRANCH_CODE = "BR-000" // Mobile has no page-size selector — tapping "Show more" just grows the page size and // re-fetches from page 1, so previously loaded cards stay put and more append below. const MOBILE_PAGE_SIZE_STEP = 10 @@ -329,12 +333,16 @@ export function BranchMasterPage() { size="icon-sm" className={cn( item.status === "Active" - ? "text-destructive hover:bg-destructive/10" + ? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" : "text-success hover:bg-success/10" )} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} - title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} - disabled={actionPendingId === item.branchId} + title={ + item.status === "Active" && item.code === DEFAULT_BRANCH_CODE + ? "The default branch cannot be deactivated" + : item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}` + } + disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)} onClick={() => toggleStatus(item)} > {item.status === "Active" ? : } @@ -345,10 +353,10 @@ export function BranchMasterPage() {
InvoiceDateDate Balance Applied
{inv.invoiceNo}{new Date(inv.invoiceDate).toLocaleDateString()}{inv.invoiceNo}{new Date(inv.invoiceDate).toLocaleDateString()} {formatAmount(inv.totals.balanceAmount)} {applied > 0 ? formatAmount(applied) : "—"}