ui and bug fixers 29/09 #92

Closed
kalana_Thilakarathna wants to merge 1 commits from ui-and-bug-fixers into Dev
85 changed files with 30050 additions and 750 deletions
@@ -28,14 +28,20 @@ public sealed class AuthController : ControllerBase
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
private readonly IUserManagementService _userManagement;
private readonly ICurrentUser _currentUser;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
public AuthController(
IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit,
IUserManagementService userManagement, ICurrentUser currentUser)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
_userManagement = userManagement;
_currentUser = currentUser;
}
/// <summary>
@@ -139,6 +145,8 @@ public sealed class AuthController : ControllerBase
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
// AuthHex accepted the new password — release the first-login forced change.
await _userManagement.ClearMustChangePasswordAsync(_currentUser.AuditUserId, ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
@@ -23,6 +23,12 @@ public class BranchConfig
/// single-branch already).</summary>
public bool WarehouseBranchingEnabled { get; set; } = true;
/// <summary>Independent sub-toggle for Purchase Orders. On: each PO belongs to one branch,
/// non-admins only see their own branch's POs, and line warehouses must be in that branch.
/// Off: POs form one shared pool across branches (kept on the default branch, visible to
/// everyone, any warehouse allowed). Meaningless when <see cref="BranchingEnabled"/> is off.</summary>
public bool PurchaseOrderBranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
+8
View File
@@ -31,6 +31,14 @@ public class User
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Set when an admin creates the account with an issued (temporary) password
/// (<c>UserManagementService.CreateAsync</c>); surfaced via <c>GET /auth/me</c> so the
/// frontend forces a password change on first login. Cleared by
/// <c>POST /auth/change-password</c>.
/// </summary>
public bool MustChangePassword { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
@@ -14,6 +14,8 @@ public class Vendor
public string Name { get; set; } = string.Empty;
public string? Terms { get; set; }
public string? TaxReg { get; set; }
public string? Address { get; set; }
public string? Phone { get; set; }
public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -4,7 +4,8 @@ namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
public sealed record BranchConfigDto(
bool BranchingEnabled, bool WarehouseBranchingEnabled, bool PurchaseOrderBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -18,4 +19,8 @@ public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
[Required] public bool? WarehouseBranchingEnabled { get; set; }
/// <summary>Optional so existing callers that only know the first two flags keep working —
/// omitted means "leave as is".</summary>
public bool? PurchaseOrderBranchingEnabled { get; set; }
}
@@ -18,6 +18,7 @@ public sealed record DashboardStatsDto(
decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders,
int FullyReceivedPurchaseOrders,
int PendingGrns,
int OpenRequisitions,
int PendingCounts,
+7 -2
View File
@@ -8,7 +8,12 @@ namespace ERPCore.Dtos.Rbac;
/// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role.</summary>
/// entirely rather than merely restricting them by role. <see cref="MustChangePassword"/>
/// is true for an admin-created account that hasn't replaced its issued password yet —
/// the frontend holds the user on the change-password screen until it is false.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
bool MustChangePassword = false,
bool PurchaseOrderBranchingEnabled = true,
int? UserId = null);
+5 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
int VendorId, string Code, string Name, string? Terms, string? TaxReg, string? Address, string? Phone,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
@@ -14,6 +14,8 @@ public sealed class CreateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
@@ -26,6 +28,8 @@ public sealed class UpdateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
public int BranchId { get; set; }
}
@@ -107,11 +107,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- RecoveryManager ------------------------------------------------
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
// RecoveryManagerService returns its own { success, message, data } object, which the
// function handler then wraps again as the envelope's `data` — so the payload sits one
// level deeper than for other AuthHex calls (ERP_Auth_Service RecoveryManager.cs).
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
=> Unwrap(await CallAsync<AuthHexEnvelope<ForgotPasswordResponse>>("recovery", "forgotPassword", request, null, ct));
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
@@ -149,6 +152,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- Transport --------------------------------------------------------
/// <summary>Unwraps the inner result object that the recovery functions nest inside the envelope's data.</summary>
private static T Unwrap<T>(AuthHexEnvelope<T>? inner)
{
if (inner is null || !inner.Success || inner.Data is null)
throw new DomainException(ErrorCodes.AuthUpstreamError, inner?.Message ?? "Authentication request failed.", 400);
return inner.Data;
}
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
@@ -21,6 +21,8 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// Defaults on so the existing row keeps today's per-branch purchase orders.
builder.Property(c => c.PurchaseOrderBranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -18,6 +18,8 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Address).HasMaxLength(500);
builder.Property(v => v.Phone).HasMaxLength(30);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status)
@@ -195,7 +195,12 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Purchase orders are only scoped while the "divide purchase orders by branch" toggle is
// on — off turns them into one shared pool every branch can see.
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive
|| e.BranchId == _currentBranch.BranchId
|| !Set<Domain.Entities.BranchConfig>().Any(c => c.ConfigId == Domain.Entities.BranchConfig.SingletonId
&& c.BranchingEnabled && c.PurchaseOrderBranchingEnabled));
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUserMustChangePassword : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "MustChangePassword",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "users");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddVendorAddressPhone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "vendors",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Phone",
table: "vendors",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Address",
table: "vendors");
migrationBuilder.DropColumn(
name: "Phone",
table: "vendors");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseOrderBranchingToggle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config");
}
}
}
@@ -344,6 +344,11 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("PurchaseOrderBranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -6394,6 +6399,9 @@ namespace ERPCore.Migrations
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId")
.HasColumnType("integer");
@@ -6427,6 +6435,7 @@ namespace ERPCore.Migrations
{
UserId = 1,
DisplayName = "System",
MustChangePassword = false,
Status = "Active",
Username = "system"
});
@@ -6440,6 +6449,10 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId")
.HasColumnType("integer");
@@ -6463,6 +6476,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -1,25 +1,54 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : IAuthRecoveryService
{
/// <summary>Longer than any AuthHex recovery OTP lives; only needs to outlast the flow.</summary>
private static readonly TimeSpan RecoveryIdentifierTtl = TimeSpan.FromMinutes(30);
private readonly IAuthHexClient _authHex;
private readonly IUserManagementService _userManagement;
private readonly IMemoryCache _cache;
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
{
_authHex = authHex;
_userManagement = userManagement;
_cache = cache;
}
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct);
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
{
var response = await _authHex.ForgotPasswordAsync(request, ct);
// The reset call only carries the reference number — remember who it was for, so a
// successful reset can also release a pending first-login forced password change.
if (!string.IsNullOrEmpty(response.ReferenceNumber))
_cache.Set(CacheKey(response.ReferenceNumber), request.Identifier.Trim(), RecoveryIdentifierTtl);
return response;
}
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct);
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
{
await _authHex.ResetPasswordAsync(request, ct);
var key = CacheKey(request.ReferenceNumber);
if (_cache.TryGetValue(key, out string? identifier) && !string.IsNullOrEmpty(identifier))
{
_cache.Remove(key);
await _userManagement.ClearMustChangePasswordByIdentifierAsync(identifier, ct);
}
}
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
}
@@ -54,6 +54,8 @@ public sealed class BranchConfigService : IBranchConfigService
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
config.PurchaseOrderBranchingEnabled = poBranching;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -110,6 +112,25 @@ public sealed class BranchConfigService : IBranchConfigService
return await ResolveDefaultBranchIdAsync(ct);
}
public async Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
return (config?.BranchingEnabled ?? true) && (config?.PurchaseOrderBranchingEnabled ?? true);
}
public async Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsPurchaseOrderBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
return await ResolveDefaultBranchIdAsync(ct);
}
// Branching (or warehouse branching specifically) is off — ignore whatever was posted and
// use the deployment's one default branch, creating it on first use (same placeholder
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
@@ -125,5 +146,6 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
private static BranchConfigDto Map(BranchConfig c) => new(
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+3 -1
View File
@@ -84,6 +84,8 @@ public sealed class DashboardService : IDashboardService
// not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
@@ -105,7 +107,7 @@ public sealed class DashboardService : IDashboardService
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices);
}
}
@@ -29,6 +29,14 @@ public interface IBranchConfigService
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>True when Purchase Orders are divided by branch. Never true when
/// <see cref="IsBranchingEnabledAsync"/> is false.</summary>
Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
/// <see cref="IsPurchaseOrderBranchingEnabledAsync"/> — off puts every PO on the default branch.</summary>
Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
/// first use if it doesn't exist yet. Used as a fallback branch for document types that
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
@@ -27,6 +27,12 @@ public interface IUserManagementService
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>Clears the first-login forced password change once the user has set their own password.</summary>
Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default);
/// <summary>Same, for a forgot-password reset — resolves the user by email or username.</summary>
Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
@@ -88,7 +88,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId;
@@ -305,9 +305,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.
/// Skipped unless both purchase orders AND warehouses are divided by branch: with PO
/// branching off the PO sits on the default branch, and with warehouse branching off every
/// warehouse is parked on the default branch too (shared by all branches) — either way a
/// branch match is meaningless and would reject every line.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{
if (!await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)) return;
if (!await _branchConfig.IsWarehouseBranchingEnabledAsync(ct)) return;
var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
+19 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,13 @@ public sealed class RoleService : IRoleService
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> 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;
@@ -202,6 +204,22 @@ public sealed class RoleService : IRoleService
}
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
var me = await BuildMeAsync(roleCode, ct) with
{
PurchaseOrderBranchingEnabled = await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)
};
if (!_currentUser.IsAuthenticated) return me;
var mustChangePassword = await _users.Query().AsNoTracking()
.Where(u => u.UserId == _currentUser.AuditUserId)
.Select(u => u.MustChangePassword)
.FirstOrDefaultAsync(ct);
// Local user id lets the frontend spot records the caller created (e.g. self-approval of a PO).
return me with { MustChangePassword = mustChangePassword, UserId = _currentUser.AuditUserId };
}
private async Task<MeResponseDto> BuildMeAsync(string? roleCode, CancellationToken ct)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
@@ -124,7 +124,9 @@ public sealed class UserManagementService : IUserManagementService
DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(),
RoleId = role.RoleId,
Status = EntityStatus.Active
Status = EntityStatus.Active,
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
MustChangePassword = true
};
await _users.AddAsync(user, ct);
@@ -183,6 +185,27 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct);
if (user is null || !user.MustChangePassword) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default)
{
var term = identifier.Trim();
var user = await _users.Query()
.FirstOrDefaultAsync(u => u.MustChangePassword
&& ((u.Email != null && EF.Functions.ILike(u.Email, term)) || u.Username == term), ct);
if (user is null) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+7 -1
View File
@@ -62,6 +62,8 @@ public sealed class VendorService : IVendorService
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Address = Normalize(request.Address),
Phone = Normalize(request.Phone),
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
BranchId = branchId,
@@ -94,6 +96,8 @@ public sealed class VendorService : IVendorService
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Address = Normalize(request.Address);
vendor.Phone = Normalize(request.Phone);
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow;
@@ -120,6 +124,8 @@ public sealed class VendorService : IVendorService
await _uow.SaveChangesAsync(ct);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
}
@@ -0,0 +1,181 @@
"use client"
// Forced first-login password change: admin-created accounts land here (via the login page
// and AuthProvider's guard) while GET /auth/me reports mustChangePassword. The server clears
// the flag and ends the session on success, so the user signs in again with the new password.
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Check, Eye, EyeOff } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
function PasswordInput({
id,
label,
value,
onChange,
placeholder,
autoComplete,
invalid,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder: string
autoComplete: string
invalid?: boolean
}) {
const [show, setShow] = useState(false)
return (
<div>
<label htmlFor={id} className="block text-sm font-semibold text-foreground">{label}</label>
<div className="relative mt-2">
<Input
id={id}
type={show ? "text" : "password"}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder={placeholder}
aria-invalid={invalid}
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{show ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
)
}
export default function ChangePasswordPage() {
const router = useRouter()
const [current, setCurrent] = useState("")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(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 differsFromCurrent = password.length > 0 && password !== current
const passwordsMatch = password === confirm && password.length > 0
const allValid = current.length > 0 && meetsLength && hasUpper && hasNumber && hasSpecial && differsFromCurrent && passwordsMatch
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
if (!allValid) return
setSubmitError(null)
setSubmitting(true)
try {
await authApi.changePassword(current, password)
clearStoredUser()
router.replace("/login?passwordChanged=1")
} catch (err) {
setSubmitError(errorMessage(err))
setSubmitting(false)
}
}
async function signOut() {
await authApi.logout().catch(() => {})
clearStoredUser()
router.replace("/login")
}
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">First sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Set your password</h1>
<p className="mt-3 text-base text-muted-foreground">
Your account was created with a temporary password. Choose a new one to continue.
</p>
</div>
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<PasswordInput
id="current"
label="Temporary Password"
value={current}
onChange={setCurrent}
placeholder="Enter the password you signed in with"
autoComplete="current-password"
/>
<PasswordInput
id="password"
label="New Password"
value={password}
onChange={setPassword}
placeholder="Enter your new password"
autoComplete="new-password"
/>
<div>
<PasswordInput
id="confirm"
label="Confirm Password"
value={confirm}
onChange={setConfirm}
placeholder="Confirm your new password"
autoComplete="new-password"
invalid={!passwordsMatch && confirm.length > 0}
/>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
<PasswordRequirement met={differsFromCurrent} label="Different from the temporary password" />
</div>
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Saving…" : "Change password"}
</Button>
<div className="text-center">
<button type="button" onClick={signOut} className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Sign out
</button>
</div>
</form>
</AuthShell>
)
}
@@ -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, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
@@ -12,23 +12,44 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { FilterBar } from "@/components/ui/filter-bar"
import { Field, FieldLabel } from "@/components/ui/field"
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size,
// same convention as the other list pages even though this one pages client-side.
const MOBILE_PAGE_SIZE_STEP = 10
export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => setPage(1), [debouncedSearch, accountType])
// 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.
// There's no server pagination on this endpoint (it returns the full union in one array), so
// page/pageSize below are applied client-side over whatever this call returns.
useEffect(() => {
let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
@@ -40,6 +61,7 @@ export default function BankAccountsPage() {
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
return () => {
cancelled = true
}
@@ -49,7 +71,7 @@ export default function BankAccountsPage() {
const filtered = useMemo(() => {
if (!accounts) return null
const q = search.trim().toLowerCase()
const q = debouncedSearch.toLowerCase()
if (!q) return accounts
return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId)
@@ -62,116 +84,201 @@ export default function BankAccountsPage() {
(gl?.accountCode ?? "").toLowerCase().includes(q)
)
})
}, [accounts, search, glAccountsById])
}, [accounts, debouncedSearch, glAccountsById])
const stats = useMemo(() => {
if (!accounts) return null
return {
total: accounts.length,
bank: accounts.filter((a) => a.accountType === CashBankAccountType.Bank).length,
cash: accounts.filter((a) => a.accountType === CashBankAccountType.Cash).length,
}
}, [accounts])
const totalItems = filtered?.length ?? 0
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
const pageItems = filtered?.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) ?? null
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
setShowMoreLoading(false)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
{accounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{accounts.length}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Account
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, bank/type, account no. or currency…"
className="h-14 w-full pl-11 text-base"
aria-label="Search cash/bank accounts"
/>
</div>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger className="h-14! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{stats ? (
<>
<StatCard
label="Total Accounts"
value={stats.total}
icon={Wallet}
active={accountType === "Both"}
onClick={() => setAccountType("Both")}
/>
<StatCard
label="Bank Accounts"
value={stats.bank}
icon={Banknote}
active={accountType === CashBankAccountType.Bank}
onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
/>
<StatCard
label="Cash Accounts"
value={stats.cash}
icon={Wallet}
active={accountType === CashBankAccountType.Cash}
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
/>
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search name, bank/type, account no. or currency…"
advancedActive={accountType !== "Both"}
onClearAdvanced={() => setAccountType("Both")}
advanced={
<Field>
<FieldLabel htmlFor="bank-filter-type">Type</FieldLabel>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Both">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && filtered === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
{!error && pageItems === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Wallet className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
</p>
{!error && pageItems !== null && pageItems.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No accounts match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different name, bank or account number.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && filtered !== null && filtered.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => {
{!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Wallet className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
<p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Account
</Link>
</div>
)}
{!error && pageItems !== null && pageItems.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`}>
<TableCell className="px-3 py-3.5">
<div key={`${a.accountType}-${a.accountId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{a.accountName}</p>
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
"h-6 w-16 shrink-0 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bank / Cash type</p>
<p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Currency</p>
<p className="font-medium text-foreground">{a.currencyCode}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">GL account</p>
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
>
<Pencil className="size-4" />
</TooltipTrigger>
@@ -180,12 +287,94 @@ export default function BankAccountsPage() {
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
</div>
</div>
)
})}
</TableBody>
</Table>
{totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
</div>
)
@@ -18,6 +18,7 @@ import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
import { useAuth } from "@/components/auth/AuthProvider"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
@@ -38,6 +39,7 @@ export default function ChequeBookDetailPage() {
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const { branchingEnabled } = useAuth()
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
@@ -99,10 +101,12 @@ export default function ChequeBookDetailPage() {
<p className="text-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium">
@@ -57,7 +57,10 @@ export default function NewChequeBookPage() {
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
// still needs *some* branch id regardless of our toggle.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
// With branching off the field is hidden entirely, but GL still needs a branch id — use the
// deployment's default branch (BR-000, the one the backend files everything under then).
const defaultBranchId = (branches.find((b) => b.code === "BR-000") ?? branches[0])?.branchId ?? null
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : defaultBranchId
const showBranchPicker = !lockedBranchId
useEffect(() => {
@@ -137,6 +140,7 @@ export default function NewChequeBookPage() {
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
{showBranchPicker ? (
@@ -163,6 +167,7 @@ export default function NewChequeBookPage() {
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
<Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react"
import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -15,152 +15,425 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
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"
type StatusFilter = ChequeBookStatus | "All"
interface StatusCounts {
total: number
active: number
completed: number
cancelled: number
}
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// 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
export default function ChequeBooksPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([
chequeBooksApi.list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
])
.then(([result, banks]) => {
if (cancelled) return
setBooks(result.items)
setTotalItems(result.totalCount)
setBankAccounts(banks)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => {
cancelled = true
}
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, activeBranchId, branchingEnabled])
}, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ChequeBookStatus) =>
chequeBooksApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ChequeBookStatus.Active),
countFor(ChequeBookStatus.Completed),
countFor(ChequeBookStatus.Cancelled),
]).then(([total, active, completed, cancelled]) => setStatusCounts({ total, active, completed, cancelled }))
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleBooks = useMemo(() => {
if (!books) return null
const q = debouncedSearch.toLowerCase()
if (!q) return books
return books.filter((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
const branch = branchesById.get(b.branchId)
return (
b.chequeBookNo.toLowerCase().includes(q) ||
(bank?.accountName ?? "").toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [books, debouncedSearch, bankAccountsById, branchesById])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div>
</div>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Cheque Book
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{statusCounts ? (
<>
<StatCard
label="Total Cheque Books"
value={statusCounts.total}
icon={BookText}
active={status === "All"}
onClick={() => setStatus("All")}
/>
<StatCard
label="Active"
value={statusCounts.active}
icon={CheckCircle2}
active={status === ChequeBookStatus.Active}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
/>
<StatCard
label="Completed"
value={statusCounts.completed}
icon={BookCheck}
active={status === ChequeBookStatus.Completed}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
/>
<StatCard
label="Cancelled"
value={statusCounts.cancelled}
icon={BookX}
active={status === ChequeBookStatus.Cancelled}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search cheque book no., bank or branch…"
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && books === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
{!error && visibleBooks === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!error && books !== null && books.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No cheque books yet.</p>
{!error && visibleBooks !== null && visibleBooks.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cheque books match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque book no., bank or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && books !== null && books.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{books.map((b) => {
{!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
{!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
</div>
{!hasFilters && (
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Cheque Book
</Link>
)}
</div>
)}
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleBooks.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
<div
key={b.chequeBookNo}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
</TableRow>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bank account</p>
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Range</p>
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
</div>
<div>
<p className="text-muted-foreground">Leaves</p>
<p className="font-medium text-foreground">{b.totalLeaves}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</div>
)
})}
</TableBody>
</Table>
{totalItems > visibleBooks.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleBooks.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className="font-semibold text-foreground hover:underline"
>
{b.chequeBookNo}
</Link>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
)}
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
</div>
)
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -14,14 +14,27 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
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 { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All"
interface StatusCounts {
total: number
received: number
deposited: number
cleared: number
returned: number
cancelled: number
}
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
@@ -30,12 +43,27 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// 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
export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -43,124 +71,385 @@ export default function ReceivedChequesPage() {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => {
let cancelled = false
receivedChequesApi
.list({
page,
pageSize,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
if (!cancelled) setCheques(res.items)
if (cancelled) return
setCheques(res.items)
setTotalItems(res.totalCount)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => {
cancelled = true
}
}, [status, activeBranchId, branchingEnabled])
}, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ReceivedChequeStatus) =>
receivedChequesApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ReceivedChequeStatus.Received),
countFor(ReceivedChequeStatus.Deposited),
countFor(ReceivedChequeStatus.Cleared),
countFor(ReceivedChequeStatus.Returned),
countFor(ReceivedChequeStatus.Cancelled),
]).then(([total, received, deposited, cleared, returned, cancelled]) =>
setStatusCounts({ total, received, deposited, cleared, returned, cancelled })
)
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleCheques = useMemo(() => {
if (!cheques) return null
const q = debouncedSearch.toLowerCase()
if (!q) return cheques
return cheques.filter((c) => {
const branch = c.branchId ? branchesById.get(c.branchId) : null
return (
c.chequeNo.toLowerCase().includes(q) ||
c.receivedFromName.toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [cheques, debouncedSearch, branchesById])
function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated)
}
function openDialog(c: ReceivedCheque) {
setSelected(c)
setDialogOpen(true)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div>
</div>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Received Cheque
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{statusCounts ? (
<>
<StatCard
label="Total Received"
value={statusCounts.total}
icon={Inbox}
active={status === "All"}
onClick={() => setStatus("All")}
/>
<StatCard
label="Received"
value={statusCounts.received}
icon={Wallet}
active={status === ReceivedChequeStatus.Received}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
/>
<StatCard
label="Deposited"
value={statusCounts.deposited}
icon={Banknote}
active={status === ReceivedChequeStatus.Deposited}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
/>
<StatCard
label="Cleared"
value={statusCounts.cleared}
icon={CheckCircle2}
active={status === ReceivedChequeStatus.Cleared}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
/>
<StatCard
label="Returned"
value={statusCounts.returned}
icon={Undo2}
active={status === ReceivedChequeStatus.Returned}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
/>
</>
) : (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && cheques === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
{!error && visibleCheques === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!error && cheques !== null && cheques.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Inbox className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No received cheques yet.</p>
{!error && visibleCheques !== null && visibleCheques.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cheques match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque no., name or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && cheques !== null && cheques.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cheques.map((c) => (
<TableRow
{!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Inbox className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
{!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
</div>
{!hasFilters && (
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Received Cheque
</Link>
)}
</div>
)}
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleCheques.map((c) => (
<div
key={c.receivedChequeId}
className="cursor-pointer"
onClick={() => {
setSelected(c)
setDialogOpen(true)
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
}}
onKeyDown={(e) => {
if (e.key === "Enter") openDialog(c)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
</TableRow>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Received from</p>
<p className="font-medium text-foreground">{c.receivedFromName}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.receivedFromType}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</div>
))}
</TableBody>
</Table>
{totalItems > visibleCheques.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleCheques.map((c) => (
<TableRow
key={c.receivedChequeId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
}}
>
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
+71 -21
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import {
CheckCircle2,
Clock,
Eye,
HelpCircle,
Inbox,
Plus,
@@ -19,7 +20,7 @@ import { SupportTicketSummary } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -63,6 +64,7 @@ export default function HelpPage() {
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState("")
@@ -87,6 +89,12 @@ export default function HelpPage() {
return () => clearTimeout(t)
}, [search])
useEffect(() => setPage(1), [statusFilter])
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
setStatusFilter((s) => (s === status ? null : status))
}
const stats = useMemo(() => {
if (!tickets) return null
return {
@@ -100,11 +108,12 @@ export default function HelpPage() {
const filtered = useMemo(() => {
if (!tickets) return []
const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
(t) =>
(!statusFilter || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
)
}, [tickets, debouncedSearch])
}, [tickets, debouncedSearch, statusFilter])
const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -207,16 +216,40 @@ export default function HelpPage() {
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="flex flex-wrap gap-2">
{stats ? (
<>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
<StatCard label="Open" value={stats.open} icon={Inbox} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
<StatCard
label="Total tickets"
value={stats.total}
icon={HelpCircle}
active={statusFilter === null}
onClick={() => setStatusFilter(null)}
/>
<StatCard
label="Open"
value={stats.open}
icon={Inbox}
active={statusFilter === "Open"}
onClick={() => toggleStatusFilter("Open")}
/>
<StatCard
label="In Progress"
value={stats.inProgress}
icon={Clock}
active={statusFilter === "InProgress"}
onClick={() => toggleStatusFilter("InProgress")}
/>
<StatCard
label="Resolved"
value={stats.resolved}
icon={CheckCircle2}
active={statusFilter === "Resolved"}
onClick={() => toggleStatusFilter("Resolved")}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div>
@@ -264,15 +297,17 @@ export default function HelpPage() {
</div>
)}
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
{!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No tickets match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
<p className="text-base font-medium text-foreground">
{debouncedSearch ? <>No tickets match &quot;{debouncedSearch}&quot;</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
</p>
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
<Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
Clear filters
</Button>
</div>
)}
@@ -294,11 +329,7 @@ export default function HelpPage() {
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => (
<Link
key={t.ticketId}
href={`/dashboard/help/tickets/${t.ticketId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div key={t.ticketId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
@@ -323,7 +354,13 @@ export default function HelpPage() {
</p>
</div>
</div>
</Link>
<div className="flex items-center justify-end border-t pt-3">
<Link href={`/dashboard/help/tickets/${t.ticketId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Eye className="size-4" />
View
</Link>
</div>
</div>
))}
{totalItems > pageItems.length && (
@@ -344,6 +381,7 @@ export default function HelpPage() {
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -371,6 +409,18 @@ export default function HelpPage() {
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ticket ${t.subject}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -167,13 +167,31 @@ export default function EmployeesPage() {
</Link>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="flex flex-wrap gap-2">
{stats ? (
<>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
<StatCard
label="Total employees"
value={stats.total}
icon={UsersIcon}
active={statusFilter === undefined}
onClick={() => setStatusFilter(undefined)}
/>
<StatCard
label="Active"
value={stats.active}
icon={UserCheck}
active={statusFilter === "Active"}
onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))}
/>
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
<StatCard
label="Terminated"
value={stats.terminated}
icon={UserMinus}
active={statusFilter === "Terminated"}
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { CheckCircle2, Clock, PenLine, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -10,7 +10,6 @@ import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -41,8 +40,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
}
export default function LeaveRequestsPage() {
const stats = useDashboardStats()
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
@@ -91,6 +90,22 @@ export default function LeaveRequestsPage() {
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
// Counts for the stat cards — one cheap pageSize:1 call per status. Each call falls back
// to 0 on its own so one bad request doesn't blank out the other tiles.
function loadCounts() {
Promise.all(
STATUSES.map((s) =>
leaveRequestsApi
.list({ page: 1, pageSize: 1, status: s })
.then((res) => res.pagination.totalItems)
.catch(() => 0)
)
).then((values) => {
setStatusCounts(Object.fromEntries(STATUSES.map((s, i) => [s, values[i]])) as Record<LeaveRequestStatus, number>)
})
}
useEffect(loadCounts, [])
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
@@ -113,7 +128,7 @@ export default function LeaveRequestsPage() {
toast.success("Leave request submitted")
setOpen(false)
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
load()
load(); loadCounts()
} catch (err) {
toast.error("Could not submit leave request", errorMessage(err))
} finally {
@@ -126,7 +141,7 @@ export default function LeaveRequestsPage() {
try {
await leaveRequestsApi.approve(id)
toast.success("Leave approved")
load()
load(); loadCounts()
} catch (err) {
toast.error("Could not approve", errorMessage(err))
} finally {
@@ -149,7 +164,7 @@ export default function LeaveRequestsPage() {
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
toast.success("Leave rejected")
setRejectTarget(null)
load()
load(); loadCounts()
} catch (err) {
toast.error("Could not reject", errorMessage(err))
} finally {
@@ -238,7 +253,49 @@ export default function LeaveRequestsPage() {
</DialogContent>
</Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
<KpiRow
tiles={
statusCounts
? [
{
label: "Draft",
value: statusCounts.Draft,
icon: PenLine,
active: statusFilter === "Draft",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Draft" ? ALL_STATUS : "Draft")) },
},
{
label: "Pending Leave",
value: statusCounts.Submitted,
icon: Clock,
active: statusFilter === "Submitted",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
},
{
label: "Approved",
value: statusCounts.Approved,
icon: CheckCircle2,
active: statusFilter === "Approved",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Approved" ? ALL_STATUS : "Approved")) },
},
{
label: "Rejected",
value: statusCounts.Rejected,
icon: XCircle,
active: statusFilter === "Rejected",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Rejected" ? ALL_STATUS : "Rejected")) },
},
{
label: "Cancelled",
value: statusCounts.Cancelled,
icon: X,
active: statusFilter === "Cancelled",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Cancelled" ? ALL_STATUS : "Cancelled")) },
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs">
@@ -155,7 +155,7 @@ export default function PayrollRunDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
@@ -1,8 +1,9 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus } from "lucide-react"
import { Eye, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -10,7 +11,7 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
import { Button } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -210,6 +211,7 @@ export default function PayrollRunsPage() {
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -229,6 +231,18 @@ export default function PayrollRunsPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label={`View payroll run ${r.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { useAuth } from "@/components/auth/AuthProvider"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__"
@@ -31,6 +32,7 @@ export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const { branchingEnabled } = useAuth()
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
@@ -227,18 +229,20 @@ export default function DepartmentsPage() {
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
@@ -282,18 +286,20 @@ export default function DepartmentsPage() {
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
+8 -8
View File
@@ -129,30 +129,30 @@ export default function DashboardPage() {
</div>
)}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
<div className="flex flex-wrap gap-2">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
</Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
</Link>
</>
) : (
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20 w-52 rounded-2xl" />)
)}
</div>
@@ -29,6 +29,7 @@ import {
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -55,7 +56,7 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const poId = Number(params.id)
const { isAdmin } = useAuth()
const { isAdmin, userId } = useAuth()
const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null)
@@ -121,6 +122,9 @@ export default function PurchaseOrderDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poId])
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
@@ -307,6 +311,19 @@ export default function PurchaseOrderDetailPage() {
// a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
// Mirrors PurchaseOrderService.LoadForApprovalDecisionAsync: only an Admin who didn't
// create the PO may approve/reject it. Explain why instead of silently hiding the buttons.
const isOwnPo = userId !== null && po.createdBy === userId
const approvalBlockedReason =
po.status !== "PendingApproval"
? null
: !isAdmin
? "This purchase order is awaiting approval. Only an Admin can approve or reject it."
: isOwnPo
? "You created this purchase order, so you can't approve or reject it yourself. Another Admin must review it."
: null
const canDecide = po.status === "PendingApproval" && approvalBlockedReason === null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -346,7 +363,7 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog>
</>
)}
{po.status === "PendingApproval" && isAdmin && (
{canDecide && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" />
@@ -374,6 +391,13 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
{approvalBlockedReason && (
<div role="status" className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<p>{approvalBlockedReason}</p>
</div>
)}
{showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
@@ -485,18 +509,16 @@ export default function PurchaseOrderDetailPage() {
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
<Button
@@ -593,7 +615,7 @@ export default function PurchaseOrderDetailPage() {
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -623,18 +645,17 @@ export default function PurchaseOrderDetailPage() {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
popupClassName="min-w-96"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
@@ -36,6 +36,7 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -69,7 +70,9 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
// The PO's branch follows the purchase-order sub-toggle — off means no branch is asked for
// and the server files the PO on the default branch.
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, purchaseOrderBranchingEnabled: branchingEnabled, warehouseBranchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
@@ -92,6 +95,8 @@ function NewPurchaseOrderContent() {
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vAddress, setVAddress] = useState("")
const [vPhone, setVPhone] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
@@ -131,6 +136,8 @@ function NewPurchaseOrderContent() {
setVName("")
setVTerms("")
setVTaxReg("")
setVAddress("")
setVPhone("")
setVCurrency("LKR")
setVErrors({})
}
@@ -149,6 +156,8 @@ function NewPurchaseOrderContent() {
name: vName,
terms: vTerms || null,
taxReg: vTaxReg || null,
address: vAddress || null,
phone: vPhone || null,
currency: vCurrency,
})
await loadVendors()
@@ -232,6 +241,14 @@ function NewPurchaseOrderContent() {
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
// Line warehouses must sit in the PO's branch only when warehouses are divided by branch
// too (mirrors PurchaseOrderService.EnsureLinesInBranchAsync); otherwise every warehouse
// is shared and all of them are offered.
const lineWarehouses =
branchingEnabled && warehouseBranchingEnabled && effectiveBranchId
? (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)
: (warehouses ?? [])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -240,6 +257,9 @@ function NewPurchaseOrderContent() {
setLines((prev) => prev.filter((l) => l.key !== key))
}
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = (items ?? []).map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
@@ -377,6 +397,14 @@ function NewPurchaseOrderContent() {
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-address">Address (optional)</FieldLabel>
<Input id="po-v-address" value={vAddress} onChange={(e) => setVAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-phone">Phone (optional)</FieldLabel>
<Input id="po-v-phone" type="tel" value={vPhone} onChange={(e) => setVPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
</Field>
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
@@ -468,22 +496,16 @@ function NewPurchaseOrderContent() {
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -514,7 +536,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
{lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
@@ -565,7 +587,7 @@ function NewPurchaseOrderContent() {
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -584,22 +606,17 @@ function NewPurchaseOrderContent() {
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
popupClassName="min-w-96"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -615,7 +632,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
{lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -38,7 +38,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
// Every branch picker/column/filter on this screen follows the purchase-order sub-toggle,
// not the global one — off means POs are one shared pool across branches.
const { isAdmin, activeBranchId, purchaseOrderBranchingEnabled: branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([])
@@ -90,8 +92,12 @@ export default function PurchaseOrdersListPage() {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function vendorOf(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)
}
function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
return vendorOf(vendorId)?.code ?? `#${vendorId}`
}
function branchName(branchId: number) {
@@ -132,7 +138,14 @@ export default function PurchaseOrdersListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
{pagination && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pagination.totalItems}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
@@ -150,7 +163,28 @@ export default function PurchaseOrdersListPage() {
</div>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
<KpiRow
tiles={
stats
? [
{
label: "Pending Approval",
value: stats.pendingApprovalPurchaseOrders,
icon: Clock,
active: status === "PendingApproval",
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")),
},
{
label: "Fully Received",
value: stats.fullyReceivedPurchaseOrders,
icon: PackageCheck,
active: status === "FullyReceived",
onClick: () => setStatus((s) => (s === "FullyReceived" ? "All" : "FullyReceived")),
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
@@ -237,6 +271,9 @@ export default function PurchaseOrdersListPage() {
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="truncate text-sm text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</div>
<PoStatusBadge status={po.status} />
</div>
@@ -250,6 +287,17 @@ export default function PurchaseOrdersListPage() {
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Details</p>
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
<p className="font-medium text-foreground">—</p>
)}
</div>
{isAdmin && branchingEnabled && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
@@ -339,6 +387,7 @@ export default function PurchaseOrdersListPage() {
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
@@ -363,7 +412,22 @@ export default function PurchaseOrdersListPage() {
{po.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="text-sm font-medium text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-sm text-muted-foreground">
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p>{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="whitespace-nowrap">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
"—"
)}
</TableCell>
<TableCell>
<PoStatusBadge status={po.status} />
</TableCell>
@@ -373,7 +437,7 @@ export default function PurchaseOrdersListPage() {
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell>
)}
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
@@ -78,7 +78,21 @@ export default function RequisitionsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
<KpiRow
tiles={
stats
? [
{
label: "Open Requisitions",
value: stats.openRequisitions,
icon: Send,
active: status === "Submitted",
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
},
]
: null
}
/>
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -381,7 +381,7 @@ export default function ItemDetailPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
@@ -502,7 +502,7 @@ export default function NewItemPage() {
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM —{" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
<Link href="/dashboard/products/brands?tab=uom" className="font-semibold underline">
create one first
</Link>
.
@@ -236,7 +236,7 @@ export default function ItemsPage() {
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Link href="/dashboard/products/brands?tab=uom" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" />
UOMs
</Link>
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ReceiptText, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
@@ -40,6 +41,7 @@ export default function GrnDetailPage() {
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
const [printOpen, setPrintOpen] = useState(false)
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID())
@@ -155,6 +157,10 @@ export default function GrnDetailPage() {
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => setPrintOpen(true)}>
<ReceiptText className="size-5" />
GRN note
</Button>
{grn.status !== "Draft" && (
<Link
href="/dashboard/ledgers/general-ledger"
@@ -174,6 +180,8 @@ export default function GrnDetailPage() {
</div>
</div>
<GrnPrintDialog grnId={grn.grnId} docNo={grn.docNo} open={printOpen} onOpenChange={setPrintOpen} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -239,14 +239,16 @@ export default function NewGrnPage() {
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }),
purchaseOrdersApi.list({ pageSize: 200 }),
// Only receivable POs, filtered server-side so older open POs aren't cut off by the page cap.
purchaseOrdersApi.list({ pageSize: 200, status: "Approved" }),
purchaseOrdersApi.list({ pageSize: 200, status: "PartiallyReceived" }),
])
.then(([wh, it, uo, ve, po]) => {
.then(([wh, it, uo, ve, approved, partial]) => {
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setVendors(ve.items)
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
setPurchaseOrders([...approved.items, ...partial.items].sort((a, b) => b.poId - a.poId))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
@@ -631,9 +633,11 @@ export default function NewGrnPage() {
{mode === "po" && (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Purchase order</Label>
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)}>
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)} disabled={purchaseOrders?.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select an approved/partially received PO" />
<SelectValue
placeholder={purchaseOrders?.length === 0 ? "No purchase orders ready to receive" : "Select an approved/partially received PO"}
/>
</SelectTrigger>
<SelectContent>
{(purchaseOrders ?? []).map((p) => (
@@ -643,6 +647,15 @@ export default function NewGrnPage() {
))}
</SelectContent>
</Select>
{purchaseOrders?.length === 0 && (
<p className="text-sm text-muted-foreground">
Only approved or partially received POs can be received.{" "}
<Link href="/dashboard/procurement/purchase-orders" className="font-medium text-primary hover:underline">
Approve a purchase order
</Link>{" "}
first, or switch to Direct receipt.
</p>
)}
</div>
)}
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { CheckCircle2, Clock, Eye, ListChecks, PackageSearch, Plus, ReceiptText, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { vendorsApi } from "@/lib/api/vendors"
@@ -14,7 +13,6 @@ import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
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 { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
@@ -31,6 +29,7 @@ import {
TableRow,
} from "@/components/ui/table"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
type StatusFilter = GrnStatus | "All"
@@ -39,12 +38,17 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
interface StatusCounts {
pending: number
confirmed: number
closed: number
}
export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [counts, setCounts] = useState<StatusCounts | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -56,6 +60,7 @@ export default function GrnListPage() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [printGrn, setPrintGrn] = useState<GrnSummary | null>(null)
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
@@ -101,6 +106,24 @@ export default function GrnListPage() {
.finally(() => setShowMoreLoading(false))
}
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list, so the tiles agree with whatever the table actually shows for each filter.
// Each call falls back to 0 on its own, so a status with no GRNs (or one failed request)
// still shows "0" instead of blanking out every tile.
function loadCounts() {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (status: GrnStatus) =>
grnsApi
.list({ page: 1, pageSize: 1, status, branchId })
.then((res) => res.pagination.totalItems)
.catch(() => 0)
Promise.all([countFor("Draft"), countFor("Confirmed"), countFor("Closed")]).then(
([pending, confirmed, closed]) => setCounts({ pending, confirmed, closed })
)
}
useEffect(loadCounts, [activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
@@ -125,7 +148,35 @@ export default function GrnListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
<KpiRow
tiles={
counts
? [
{
label: "Pending GRNs",
value: counts.pending,
icon: Clock,
active: status === "Draft",
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")),
},
{
label: "Confirmed GRNs",
value: counts.confirmed,
icon: CheckCircle2,
active: status === "Confirmed",
onClick: () => setStatus((s) => (s === "Confirmed" ? "All" : "Confirmed")),
},
{
label: "Closed GRNs",
value: counts.closed,
icon: ListChecks,
active: status === "Closed",
onClick: () => setStatus((s) => (s === "Closed" ? "All" : "Closed")),
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
@@ -229,16 +280,22 @@ export default function GrnListPage() {
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
setPrintGrn(grn)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
if (e.key === "Enter") setPrintGrn(grn)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{grn.docNo}</p>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="block truncate font-semibold text-foreground hover:underline"
data-no-row-nav
>
{grn.docNo}
</Link>
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
</div>
<GrnStatusBadge status={grn.status} />
@@ -314,10 +371,10 @@ export default function GrnListPage() {
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
setPrintGrn(grn)
}}
>
<TableCell>
<TableCell data-no-row-nav>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-semibold text-foreground hover:underline"
@@ -352,7 +409,17 @@ export default function GrnListPage() {
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setPrintGrn(grn)}
aria-label={`View GRN note ${grn.docNo}`}
title="GRN note"
>
<ReceiptText className="size-4" />
</Button>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -383,6 +450,13 @@ export default function GrnListPage() {
)}
</>
)}
<GrnPrintDialog
grnId={printGrn?.grnId ?? null}
docNo={printGrn?.docNo}
open={printGrn !== null}
onOpenChange={(open) => { if (!open) setPrintGrn(null) }}
/>
</div>
)
}
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, ReceiptText, Save, Send, Undo2, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -309,8 +309,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onClick={() => setPrintOpen(true)}
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
>
<Printer className="size-4" />
Print
<ReceiptText className="size-4" />
Invoice
</button>
</div>
@@ -109,7 +109,7 @@ function NewSalesInvoiceContent() {
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0)
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled, warehouseBranchingEnabled } = useAuth()
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
@@ -194,9 +194,15 @@ function NewSalesInvoiceContent() {
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
// Only narrow by branch when warehouses are actually divided by branch — with that
// sub-toggle off every warehouse sits on the default branch and is shared by all
// branches, so filtering would leave every other branch with no warehouse to pick.
const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId]
() =>
effectiveBranchId === null || !warehouseBranchingEnabled
? warehouses
: warehouses.filter((w) => w.branchId === effectiveBranchId),
[warehouses, effectiveBranchId, warehouseBranchingEnabled]
)
// Switching branch can strip the currently-picked warehouse out of the visible set —
@@ -2,8 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, ReceiptText, Tag } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -31,6 +30,10 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
// Mirrors the server's "overdue" definition (DashboardService.OverdueAgeDays) — a Posted
// invoice with an outstanding balance older than this many days from its invoice date.
const OVERDUE_AGE_DAYS = 30
function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
@@ -70,7 +73,6 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
]
export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
@@ -90,6 +92,7 @@ export default function SalesInvoicesPage() {
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
const [overdueOnly, setOverdueOnly] = useState(false)
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
@@ -164,14 +167,27 @@ export default function SalesInvoicesPage() {
)
}
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
const visibleRows = useMemo(() => {
const overdueSince = new Date()
overdueSince.setDate(overdueSince.getDate() - OVERDUE_AGE_DAYS)
return (
rows?.filter(
(row) =>
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) &&
(!overdueOnly || (row.status === "Posted" && row.totals.balanceAmount > 0 && new Date(row.invoiceDate) < overdueSince))
) ?? []
)
}, [rows, query, overdueOnly])
const hasFilters =
status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined || overdueOnly
function toggleOverdueOnly() {
setOverdueOnly((prev) => {
const next = !prev
if (next) setStatus("Posted")
return next
})
}
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
@@ -206,11 +222,17 @@ export default function SalesInvoicesPage() {
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="flex flex-wrap gap-2">
{stats ? (
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
<StatCard
label="Overdue Invoices"
value={stats.overdueSalesInvoices}
icon={AlertTriangle}
active={overdueOnly}
onClick={toggleOverdueOnly}
/>
) : (
<Skeleton className="h-22.5 rounded-xl" />
<Skeleton className="h-22.5 w-64 rounded-xl" />
)}
{rows !== null ? (
<>
@@ -219,7 +241,7 @@ export default function SalesInvoicesPage() {
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div>
@@ -319,16 +341,22 @@ export default function SalesInvoicesPage() {
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
setPrintInvoice(row)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
if (e.key === "Enter") setPrintInvoice(row)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className="block truncate font-semibold text-foreground hover:underline"
data-no-row-nav
>
{row.invoiceNo}
</Link>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
@@ -362,8 +390,8 @@ export default function SalesInvoicesPage() {
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
<Printer className="size-4" />
Print
<ReceiptText className="size-4" />
Invoice
</Button>
{row.status === "Draft" && (
<Button
@@ -425,10 +453,10 @@ export default function SalesInvoicesPage() {
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
setPrintInvoice(row)
}}
>
<TableCell className="px-4 py-3.5 font-medium">
<TableCell className="px-4 py-3.5 font-medium" data-no-row-nav>
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo}
</Link>
@@ -455,10 +483,10 @@ export default function SalesInvoicesPage() {
variant="ghost"
size="icon-sm"
onClick={() => setPrintInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
aria-label={`View invoice ${row.invoiceNo}`}
title="Invoice"
>
<Printer className="size-4" />
<ReceiptText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { MapPin } from "lucide-react"
import { branchConfigApi } from "@/lib/api/branch-config"
import { useAuth } from "@/components/auth/AuthProvider"
import { errorMessage } from "@/lib/error-map"
import { BranchConfig } from "@/types/hrm"
@@ -22,6 +23,9 @@ export default function BranchConfigurationPage() {
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Re-pulls /auth/me after a save so the header, sidebar and branch-aware screens pick
// up the new flags immediately instead of after a full reload.
const { refresh: refreshAuth } = useAuth()
function load() {
setError(null)
@@ -43,6 +47,7 @@ export default function BranchConfigurationPage() {
const res = await branchConfigApi.update({ branchingEnabled: next, warehouseBranchingEnabled: config.warehouseBranchingEnabled }, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
refreshAuth()
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
@@ -59,6 +64,7 @@ export default function BranchConfigurationPage() {
const res = await branchConfigApi.update({ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: next }, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
refreshAuth()
toast.success("Configuration saved", `Warehouse branches ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
@@ -68,6 +74,26 @@ export default function BranchConfigurationPage() {
}
}
async function togglePurchaseOrders(next: boolean) {
if (!config) return
setSaving(true)
try {
const res = await branchConfigApi.update(
{ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: config.warehouseBranchingEnabled, purchaseOrderBranchingEnabled: next },
etag ?? ""
)
setConfig(res.data)
setEtag(res.etag)
refreshAuth()
toast.success("Configuration saved", `Purchase order branches ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -129,6 +155,25 @@ export default function BranchConfigurationPage() {
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.purchaseOrderBranchingEnabled}
onCheckedChange={togglePurchaseOrders}
disabled={saving || !config.branchingEnabled}
aria-label="Divide purchase orders by branch"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Divide purchase orders by branch</span>
<span className="text-sm text-muted-foreground">
On: every purchase order belongs to one branch, staff only see their own branch&apos;s
orders, and line warehouses must be in that branch. Off: purchase orders are shared
across all branches — no branch picker or Branch column, and any warehouse can be used.
Has no effect while &quot;Require branches&quot; above is off.
</span>
</div>
</div>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
@@ -8,7 +8,7 @@ import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon,
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -44,13 +44,15 @@ export default function UsersPage() {
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<EntityStatus | undefined>(undefined)
const [roleFilter, setRoleFilter] = useState<string | undefined>(undefined)
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() {
usersApi
.list({ page, pageSize, q: debouncedSearch || undefined })
.list({ page, pageSize, q: debouncedSearch || undefined, status: statusFilter, roleCode: roleFilter })
.then((res) => {
setUsers(res.items)
setPagination(res.pagination)
@@ -64,7 +66,8 @@ export default function UsersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(load, [page, pageSize, debouncedSearch, statusFilter, roleFilter])
useEffect(() => setPage(1), [statusFilter, roleFilter])
function loadStats() {
Promise.all([
@@ -121,13 +124,37 @@ export default function UsersPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="flex flex-wrap gap-2">
{stats ? (
<>
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
<StatCard
label="Total users"
value={stats.total}
icon={UsersIcon}
active={statusFilter === undefined && roleFilter === undefined}
onClick={() => { setStatusFilter(undefined); setRoleFilter(undefined) }}
/>
<StatCard
label="Active"
value={stats.active}
icon={UserCheck}
active={statusFilter === "Active"}
onClick={() => { setRoleFilter(undefined); setStatusFilter((s) => (s === "Active" ? undefined : "Active")) }}
/>
<StatCard
label="Inactive"
value={stats.total - stats.active}
icon={UserX}
active={statusFilter === "Inactive"}
onClick={() => { setRoleFilter(undefined); setStatusFilter((s) => (s === "Inactive" ? undefined : "Inactive")) }}
/>
<StatCard
label="Admins"
value={stats.admins}
icon={ShieldCheck}
active={roleFilter === "Admin"}
onClick={() => { setStatusFilter(undefined); setRoleFilter((r) => (r === "Admin" ? undefined : "Admin")) }}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -3,18 +3,17 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ClipboardList, Eye, Plus, Search } from "lucide-react"
import { CheckCircle2, ClipboardCheck, ClipboardList, Eye, PenLine, Plus, Search } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockCountSummary } from "@/types/stock"
import { CountStatus, StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
@@ -23,6 +22,14 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges"
type StatusFilter = CountStatus | "All"
interface StatusCounts {
draft: number
counted: number
posted: number
}
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// 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.
@@ -31,15 +38,16 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function CountsListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
@@ -49,19 +57,25 @@ export default function CountsListPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
useEffect(() => setPage(1), [query, status])
useEffect(() => {
setError(null)
stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.list({
page,
pageSize,
q: query || undefined,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
setCounts(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
}, [page, pageSize, query, status, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
@@ -72,8 +86,24 @@ export default function CountsListPage() {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
}, [])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s: CountStatus) =>
stockCountsApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.pagination.totalItems)
.catch(() => 0)
Promise.all([countFor("Draft"), countFor("Counted"), countFor("Posted")]).then(
([draft, counted, posted]) => setStatusCounts({ draft, counted, posted })
)
}, [activeBranchId, branchingEnabled])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
const hasFilters = query.length > 0 || status !== "All"
return (
<div className="flex flex-col gap-6">
@@ -90,7 +120,35 @@ export default function CountsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
<KpiRow
tiles={
statusCounts
? [
{
label: "Draft Counts",
value: statusCounts.draft,
icon: PenLine,
active: status === "Draft",
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")),
},
{
label: "Counted",
value: statusCounts.counted,
icon: ClipboardCheck,
active: status === "Counted",
onClick: () => setStatus((s) => (s === "Counted" ? "All" : "Counted")),
},
{
label: "Posted Counts",
value: statusCounts.posted,
icon: CheckCircle2,
active: status === "Posted",
onClick: () => setStatus((s) => (s === "Posted" ? "All" : "Posted")),
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
@@ -132,7 +190,7 @@ export default function CountsListPage() {
{!error && counts !== null && counts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No counts match your search." : "No counts yet."}</p>
<p className="text-base text-muted-foreground">{hasFilters ? "No counts match your filters." : "No counts yet."}</p>
{!hasFilters && (
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { useAuth } from "@/components/auth/AuthProvider"
interface DraftLine {
key: string
@@ -52,6 +53,7 @@ export default function NewTransferPage() {
const [loadError, setLoadError] = useState<string | null>(null)
const [branchId, setBranchId] = useState<number | "All">("All")
const { branchingEnabled } = useAuth()
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
const [srcBinId, setSrcBinId] = useState<number | null>(null)
@@ -117,10 +119,13 @@ export default function NewTransferPage() {
async function handleSubmit() {
setSubmitError(null)
if (branchId === "All") {
// With branching off there's no branch to pick — the server files the transfer on the
// default branch (TransferService → ResolveBranchIdAsync ignores what's posted).
if (branchingEnabled && branchId === "All") {
setSubmitError("Select a branch for this transfer.")
return
}
const payloadBranchId = branchingEnabled && branchId !== "All" ? branchId : 0
if (!srcWarehouseId || !destWarehouseId) {
setSubmitError("Select both a source and destination warehouse.")
return
@@ -150,7 +155,7 @@ export default function NewTransferPage() {
setSubmitting(true)
try {
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId, lines: payloadLines })
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId: payloadBranchId, lines: payloadLines })
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
} catch (err) {
@@ -180,6 +185,7 @@ export default function NewTransferPage() {
{!loading && (
<>
{branchingEnabled && (
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
<Select<number | "All">
@@ -200,6 +206,7 @@ export default function NewTransferPage() {
</Select>
<p className="text-sm text-muted-foreground">Required — the branch this transfer belongs to. Also narrows the warehouse choices below.</p>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
@@ -37,7 +37,7 @@ function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; label: str
function ValuationContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { activeBranchId, branchingEnabled } = useAuth()
const { activeBranchId, warehouseBranchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -80,9 +80,11 @@ function ValuationContent() {
}, [])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
// Warehouses only belong to a branch while "Divide warehouses by branch" is on (it's
// always off when branching itself is) — otherwise they're shared, so show them all.
const visibleWarehouses = useMemo(
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
[warehouses, activeBranchId, branchingEnabled]
() => (warehouseBranchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
[warehouses, activeBranchId, warehouseBranchingEnabled]
)
function setParam(key: "itemId" | "warehouseId", value: number) {
@@ -152,7 +154,7 @@ function ValuationContent() {
<Skeleton className="h-48 w-full" />
) : (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(3,minmax(0,16rem))]">
<StatChip icon={Boxes} label="Item" value={item?.sku ?? `#${itemId}`} />
<StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} />
<StatChip icon={BadgeDollarSign} label={`Total value (${valuation.currency})`} value={valuation.totalValue.toFixed(2)} />
+17 -2
View File
@@ -54,6 +54,8 @@ export default function VendorDetailPage() {
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [address, setAddress] = useState("")
const [phone, setPhone] = useState("")
const [currency, setCurrency] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
@@ -88,6 +90,8 @@ export default function VendorDetailPage() {
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setAddress(data.address ?? "")
setPhone(data.phone ?? "")
setCurrency(data.currency)
}
@@ -177,7 +181,7 @@ export default function VendorDetailPage() {
if (!vendor) return
setSaving(true)
try {
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag)
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, address: address || null, phone: phone || null, currency, branchId: vendor.branchId }, etag)
setVendor(result.data)
setEtag(result.etag)
setEditOpen(false)
@@ -261,6 +265,9 @@ export default function VendorDetailPage() {
</Badge>
</div>
<p className="text-base text-muted-foreground">{vendor.name}</p>
{(vendor.address || vendor.phone) && (
<p className="text-sm text-muted-foreground">{[vendor.address, vendor.phone].filter(Boolean).join(" · ")}</p>
)}
</div>
</div>
@@ -281,7 +288,7 @@ export default function VendorDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(3,minmax(0,16rem))]">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
@@ -469,6 +476,14 @@ export default function VendorDetailPage() {
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address</Label>
<Input value={address} onChange={(e) => setAddress(e.target.value)} maxLength={500} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={30} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
+13 -1
View File
@@ -60,6 +60,8 @@ export default function VendorsPage() {
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [address, setAddress] = useState("")
const [phone, setPhone] = useState("")
const [currency, setCurrency] = useState("LKR")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
@@ -125,6 +127,8 @@ export default function VendorsPage() {
setName("")
setTerms("")
setTaxReg("")
setAddress("")
setPhone("")
setCurrency("LKR")
setBranchFilterId(null)
setErrors({})
@@ -140,7 +144,7 @@ export default function VendorsPage() {
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 })
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, address: address || null, phone: phone || null, currency, branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
@@ -212,6 +216,14 @@ export default function VendorsPage() {
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field>
<FieldLabel htmlFor="v-address">Address (optional)</FieldLabel>
<Input id="v-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
</Field>
<Field>
<FieldLabel htmlFor="v-phone">Phone (optional)</FieldLabel>
<Input id="v-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
</Field>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
@@ -7,23 +7,69 @@ import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { errorMessage } from "@/lib/error-map"
import { setRecoveryState, useRecoveryState } from "@/lib/password-recovery"
const OTP_LENGTH = 6
export default function ForgotOtpPage() {
const router = useRouter()
const recovery = useRecoveryState()
const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60)
const [submitting, setSubmitting] = useState(false)
const [resending, setResending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const canResend = secondsLeft <= 0
// No recovery in progress (direct visit / reload in a new tab) — start from step 1.
useEffect(() => {
if (recovery === null) router.replace("/login/forgot")
}, [recovery, router])
useEffect(() => {
if (secondsLeft <= 0) return
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000)
return () => clearInterval(id)
}, [secondsLeft])
const handleResend = () => {
setSecondsLeft(60)
setOtp("")
const handleResend = async () => {
if (!recovery) return
setError(null)
setNotice(null)
setResending(true)
try {
const res = await authApi.forgotPassword(recovery.email)
if (!res.referenceNumber) throw new Error("Could not resend the code. Please try again.")
setRecoveryState({ ...recovery, referenceNumber: res.referenceNumber, verified: false })
setOtp("")
setSecondsLeft(60)
setNotice("A new code has been sent.")
} catch (err) {
setError(errorMessage(err))
} finally {
setResending(false)
}
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recovery || otp.length !== OTP_LENGTH) return
setError(null)
setNotice(null)
setSubmitting(true)
try {
const res = await authApi.verifyRecoveryOtp(recovery.referenceNumber, otp)
if (!res.verified) throw new Error("The code is incorrect or has expired.")
setRecoveryState({ ...recovery, referenceNumber: res.referenceNumber ?? recovery.referenceNumber, verified: true })
router.push("/login/forgot/reset")
} catch (err) {
setError(errorMessage(err))
setSubmitting(false)
}
}
return (
@@ -35,28 +81,35 @@ export default function ForgotOtpPage() {
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Verify your email</h1>
<p className="mt-3 text-base text-muted-foreground">
We&apos;ve sent a verification code to your email address. Enter it below to continue.
We&apos;ve sent a verification code to{" "}
{recovery ? <span className="font-semibold text-foreground">{recovery.email}</span> : "your email address"}.
Enter it below to continue.
</p>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
router.push("/login/forgot/reset")
}}
>
<OtpInput value={otp} onChange={(v) => setOtp(v)} />
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{error && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div role="status" className="rounded-lg border border-success/30 bg-success/5 p-4 text-sm text-success">
{notice}
</div>
)}
<OtpInput length={OTP_LENGTH} value={otp} onChange={(v) => setOtp(v)} />
<div className="text-center">
{canResend ? (
<button
type="button"
onClick={handleResend}
className="text-sm font-medium text-primary transition-colors hover:text-primary/80"
disabled={resending}
className="text-sm font-medium text-primary transition-colors hover:text-primary/80 disabled:opacity-50"
>
Didn&apos;t receive the code? Resend
{resending ? "Sending…" : "Didn't receive the code? Resend"}
</button>
) : (
<p className="text-sm text-muted-foreground">
@@ -65,8 +118,12 @@ export default function ForgotOtpPage() {
)}
</div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold">
Verify
<Button
type="submit"
disabled={otp.length !== OTP_LENGTH || submitting || !recovery}
className="mt-8 h-12 w-full rounded-full text-base font-semibold"
>
{submitting ? "Verifying…" : "Verify"}
</Button>
<div className="text-center">
+35 -14
View File
@@ -7,11 +7,16 @@ import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations"
import { authApi } from "@/lib/api/auth"
import { errorMessage } from "@/lib/error-map"
import { setRecoveryState } from "@/lib/password-recovery"
export default function ForgotEmailPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [emailError, setEmailError] = useState<string>("")
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const onEmailChange = (value: string) => {
setEmail(value)
@@ -19,6 +24,28 @@ export default function ForgotEmailPage() {
setEmailError(res.valid ? "" : (res.error?.issues?.[0]?.message ?? 'Invalid email'))
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (email.length === 0) {
setEmailError("Email is required")
return
}
if (emailError !== "") return
setSubmitError(null)
setSubmitting(true)
try {
const identifier = email.trim()
const res = await authApi.forgotPassword(identifier)
if (!res.referenceNumber) throw new Error("Could not start password recovery. Please try again.")
setRecoveryState({ email: identifier, referenceNumber: res.referenceNumber, verified: false })
router.push("/login/forgot/otp")
} catch (err) {
setSubmitError(errorMessage(err))
setSubmitting(false)
}
}
return (
<AuthShell>
<div className="text-center">
@@ -29,18 +56,12 @@ export default function ForgotEmailPage() {
</p>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
if (email.length === 0) {
setEmailError("Email is required")
return
}
if (emailError === "") router.push("/login/forgot/otp")
}}
>
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-semibold text-foreground">Email Address</label>
<Input
@@ -56,8 +77,8 @@ export default function ForgotEmailPage() {
{emailError !== "" && <p className="mt-2 text-sm font-medium text-destructive">{emailError}</p>}
</div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== ""}>
Send verification code
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== "" || submitting}>
{submitting ? "Sending…" : "Send verification code"}
</Button>
<div className="text-center">
@@ -2,7 +2,10 @@
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useEffect, useState } from "react"
import { authApi } from "@/lib/api/auth"
import { errorMessage } from "@/lib/error-map"
import { clearRecoveryState, useRecoveryState } from "@/lib/password-recovery"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -33,6 +36,9 @@ export default function ForgotResetPage() {
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const recovery = useRecoveryState()
const meetsLength = password.length >= 8 && password.length <= 20
const hasUpper = /[A-Z]/.test(password)
@@ -41,6 +47,28 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
// Only reachable after a verified OTP — otherwise restart the flow (a completed reset clears
// the state too, but the success dialog is already open by then, so don't bounce away).
useEffect(() => {
if (!showSuccess && recovery !== undefined && !recovery?.verified) router.replace("/login/forgot")
}, [recovery, showSuccess, router])
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!allValid || !recovery?.verified) return
setSubmitError(null)
setSubmitting(true)
try {
await authApi.resetPassword(recovery.referenceNumber, password, confirm)
setShowSuccess(true)
clearRecoveryState()
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<AuthShell>
<div className="text-center">
@@ -49,14 +77,12 @@ export default function ForgotResetPage() {
<p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
if (allValid) setShowSuccess(true)
}}
>
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative mt-2">
@@ -115,8 +141,8 @@ export default function ForgotResetPage() {
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div>
<Button type="submit" disabled={!allValid} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
Change password
<Button type="submit" disabled={!allValid || submitting || !recovery} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Saving…" : "Change password"}
</Button>
<div className="text-center">
@@ -126,7 +152,7 @@ export default function ForgotResetPage() {
</div>
</form>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<Dialog open={showSuccess} onOpenChange={(open) => { if (!open) router.push("/login") }}>
<DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4">
+11 -1
View File
@@ -32,7 +32,11 @@ function LoginForm() {
const [remember, setRemember] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const returnTo = searchParams.get("next")
const sessionNotice = returnTo ? "Your session is missing or expired. Sign in again to continue." : null
const sessionNotice = searchParams.get("passwordChanged")
? "Your password has been changed. Sign in with your new password."
: returnTo
? "Your session is missing or expired. Sign in again to continue."
: null
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
@@ -45,6 +49,12 @@ function LoginForm() {
// The session arrives as httpOnly cookies; the body carries only the user profile.
const session = await authApi.login({ identifier: values.email, password: values.password })
setStoredUser(session.user)
// First login on an admin-issued password — replace it before entering the app.
const me = await authApi.me().catch(() => null)
if (me?.mustChangePassword) {
router.push("/change-password")
return
}
// Honour the path the guard bounced us from, but only same-origin paths — an
// attacker-supplied absolute URL here would be an open redirect.
const next = searchParams.get("next")
@@ -43,7 +43,6 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
@@ -98,8 +97,9 @@ const navItems: {
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
// One entry for the merged Brand/UOM tab page (the old /uoms route just redirects to
// its UOM tab) — still shown to a role that only holds the UOM permission.
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
],
},
@@ -437,7 +437,9 @@ export function AppSidebar() {
const pathname = usePathname() || "/"
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { navCodes, loading } = useAuth()
const { navCodes, loading, branchingEnabled } = useAuth()
// Entries that only make sense while "Require branches" is on — hidden entirely when off.
const branchOnlyCodes = new Set(["settings.branches"])
// 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
@@ -463,9 +465,10 @@ export function AppSidebar() {
)
.map((item) => ({
...item,
children: bypassCodes.has(item.code)
children: (bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)?.filter((c) => branchingEnabled || !branchOnlyCodes.has(c.code)),
}))
// Close mobile menu on route change
@@ -5,6 +5,7 @@
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
// (that cache remains display-only, e.g. for the header's user name).
import { createContext, useContext, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { authApi } from "@/lib/api/auth"
import { MeResponse } from "@/types/rbac"
@@ -32,6 +33,10 @@ function readStoredActiveBranch(): ActiveBranch | null {
}
interface AuthContextValue {
/** Re-fetches `/auth/me` — call after changing settings it reflects (e.g. Branch Configuration). */
refresh: () => void
/** The caller's local user id — compare against a document's `createdBy`. */
userId: number | null
roleCode: string | null
roleName: string | null
navCodes: string[]
@@ -62,11 +67,19 @@ interface AuthContextValue {
* everything else. Always false when `branchingEnabled` itself is false.
*/
warehouseBranchingEnabled: boolean
/**
* Independent sub-toggle for Purchase Orders (Settings → Branch Configuration). False means
* POs are one shared pool: no branch picker/column on PO screens. Always false when
* `branchingEnabled` is false.
*/
purchaseOrderBranchingEnabled: boolean
/** True until the first `/auth/me` response lands. */
loading: boolean
}
const AuthContext = createContext<AuthContextValue>({
refresh: () => {},
userId: null,
roleCode: null,
roleName: null,
navCodes: [],
@@ -78,12 +91,17 @@ const AuthContext = createContext<AuthContextValue>({
setActiveBranch: () => {},
branchingEnabled: true,
warehouseBranchingEnabled: true,
purchaseOrderBranchingEnabled: true,
loading: true,
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<MeResponse | null>(null)
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
// Bumped by refresh() to re-fetch /auth/me — e.g. right after Branch Configuration changes,
// so the header and every branch-aware screen update without a full page reload.
const [reloadKey, setReloadKey] = useState(0)
const router = useRouter()
useEffect(() => {
let cancelled = false
@@ -95,12 +113,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.catch(() => {
// Unauthenticated/unreachable — fall back to "no permissions" rather than
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true, mustChangePassword: false, purchaseOrderBranchingEnabled: true, userId: null })
})
return () => {
cancelled = true
}
}, [])
}, [reloadKey])
useEffect(() => {
// Admin-created accounts must replace their issued password before using the app.
if (me?.mustChangePassword) router.replace("/change-password")
}, [me, router])
useEffect(() => {
if (me === null) return
@@ -115,21 +138,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
}
// With branching switched off there is no "current branch" at all — ignore any stale
// header pick (or own-branch pin) so no screen keeps silently filtering by it.
const branchingEnabled = me?.branchingEnabled ?? true
return (
<AuthContext.Provider
value={{
refresh: () => setReloadKey((k) => k + 1),
userId: me?.userId ?? null,
roleCode: me?.roleCode ?? null,
roleName: me?.roleName ?? null,
navCodes: me?.navCodes ?? [],
isAdmin: me?.isAdmin ?? false,
branchId: me?.branchId ?? null,
branchName: me?.branchName ?? null,
activeBranchId: activeBranch?.id ?? null,
activeBranchName: activeBranch?.name ?? null,
activeBranchId: branchingEnabled ? (activeBranch?.id ?? null) : null,
activeBranchName: branchingEnabled ? (activeBranch?.name ?? null) : null,
setActiveBranch,
branchingEnabled: me?.branchingEnabled ?? true,
branchingEnabled,
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true,
loading: me === null,
purchaseOrderBranchingEnabled: me?.purchaseOrderBranchingEnabled ?? true,
// Stays "loading" while a forced password change redirects away, so the shell never flashes.
loading: me === null || me.mustChangePassword,
}}
>
{children}
@@ -0,0 +1,316 @@
"use client"
// Shared print/download/send shell for printable documents (sales invoice, GRN, …). Each
// document supplies its own sheet layouts; this owns the paper-size picker, the print copy,
// PDF export and sharing, so every document behaves the same way.
import { useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Download, Printer, Send } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
export const ACCENT = "#8b1c24"
export type PaperSize = "A4" | "80mm" | "58mm"
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
{ value: "80mm", label: "80mm", hint: "Receipt roll" },
{ value: "58mm", label: "58mm", hint: "Receipt roll" },
]
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
const PAGE_RULES: Record<PaperSize, string> = {
A4: "@page { size: A4; margin: 12mm; }",
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
}
export function money(n: number) {
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function qty(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
/** dd/MM/yyyy, as on the reference invoice. */
export function formatDate(value: string | Date) {
const d = new Date(value)
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
}
export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
/** On screen: a true 210 × 297 mm page. In print the @page margin does the spacing. */
export const A4_SHEET_CLASS =
"invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
/** Thermal roll sheet (80mm, or 58mm when narrow): one centred column, no fills. */
export function receiptSheetClass(narrow?: boolean) {
return cn(
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
narrow ? "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]" : "w-[80mm] px-[4mm] py-[5mm] text-[11px]"
)
}
/** Section heading with the short accent underline. */
export function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3">
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
</div>
)
}
/** Heavy dark bar over a thin accent line, under the A4 title block. */
export function HeaderRule() {
return (
<div className="mt-5 mb-7">
<div className="h-1 bg-neutral-900" />
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
</div>
)
}
/** Dashed divider used between receipt sections. */
export function ReceiptRule() {
return <div className="my-2 border-t border-dashed border-neutral-500" />
}
export function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
<span>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
)
}
const subscribeNoop = () => () => {}
/** True only on the client — the print copy is portalled into document.body. */
function useIsClient() {
return useSyncExternalStore(subscribeNoop, () => true, () => false)
}
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
const A4_HEIGHT_MM = 297
// Stands in for an image that can't be embedded (e.g. a logo on a host without CORS) so the PDF still renders.
const BLANK_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
/** Renders the on-screen sheet to a PDF: A4 is split across pages, receipts are one long page. */
async function renderSheetPdf(node: HTMLElement, size: PaperSize, fileName: string): Promise<File> {
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
const image = await toPng(node, {
pixelRatio: 2,
backgroundColor: "#ffffff",
style: { boxShadow: "none", margin: "0" },
imagePlaceholder: BLANK_PNG,
})
const widthMm = PAPER_WIDTH_MM[size]
const heightMm = (node.offsetHeight / node.offsetWidth) * widthMm
let pdf
if (size === "A4") {
pdf = new jsPDF({ unit: "mm", format: "a4" })
for (let top = 0; top < heightMm - 1; top += A4_HEIGHT_MM) {
if (top > 0) pdf.addPage()
pdf.addImage(image, "PNG", 0, -top, widthMm, heightMm, "sheet")
}
} else {
pdf = new jsPDF({ unit: "mm", format: [widthMm, heightMm], orientation: heightMm >= widthMm ? "portrait" : "landscape" })
pdf.addImage(image, "PNG", 0, 0, widthMm, heightMm)
}
return new File([pdf.output("blob")], fileName, { type: "application/pdf" })
}
function downloadFile(file: File) {
const url = URL.createObjectURL(file)
const a = document.createElement("a")
a.href = url
a.download = file.name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/** wa.me link to a phone (local 0XXXXXXXXX numbers get the 94 country code); no phone opens the contact picker. */
function whatsAppUrl(phone: string | null | undefined, text: string) {
let digits = (phone ?? "").replace(/\D/g, "")
if (digits.startsWith("0")) digits = `94${digits.slice(1)}`
return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`
}
export interface ShareInfo {
title: string
text: string
/** WhatsApp fallback recipient; omitted opens WhatsApp's contact picker. */
phone?: string | null
}
/**
* Document preview + paper-size picker + Download / Send / Print. Download and Send render the
* previewed sheet to a PDF in the chosen size. Printing uses a copy of the sheet portalled
* straight into <body> (`.invoice-print-root`, see globals.css) so the printout is just the
* document, whether this is rendered in a page or inside a dialog.
*/
export function DocumentPrintPreview({
renderSheet,
error,
fileName,
share,
className,
}: {
/** Null while the document is loading. */
renderSheet: ((size: PaperSize) => React.ReactNode) | null
error: string | null
/** PDF file name without extension; the paper size is appended for receipts. */
fileName: string
share: ShareInfo | null
className?: string
}) {
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const [busy, setBusy] = useState<"download" | "send" | null>(null)
const sheetRef = useRef<HTMLDivElement>(null)
const isClient = useIsClient()
const ready = renderSheet !== null
async function makePdf() {
const node = sheetRef.current?.firstElementChild
if (!(node instanceof HTMLElement)) throw new Error("The document isn't ready yet.")
const suffix = paperSize === "A4" ? "" : `-${paperSize}`
return renderSheetPdf(node, paperSize, `${fileName}${suffix}.pdf`)
}
async function handleDownload() {
setBusy("download")
try {
downloadFile(await makePdf())
} catch (err) {
toast.error("Could not create PDF", errorMessage(err))
} finally {
setBusy(null)
}
}
/** Share sheet with the PDF attached where the browser supports it; otherwise download it and open WhatsApp. */
async function handleSend() {
if (!share) return
setBusy("send")
try {
const file = await makePdf()
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: share.title, text: share.text })
return
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return
// NotAllowedError: the click's activation expired while the PDF rendered — fall through to WhatsApp.
if (!(err instanceof DOMException && err.name === "NotAllowedError")) throw err
}
}
downloadFile(file)
window.open(whatsAppUrl(share.phone, share.text), "_blank", "noopener,noreferrer")
toast.info("PDF downloaded", "Attach it to the WhatsApp chat that just opened.")
} catch (err) {
toast.error("Could not send", errorMessage(err))
} finally {
setBusy(null)
}
}
if (error && !ready) {
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
return (
<div className={cn("flex min-h-0 flex-col", className)}>
{/* Preview window — scrolls so the page keeps its true size. */}
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
{ready ? (
<div ref={sheetRef}>{renderSheet(paperSize)}</div>
) : (
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
)}
</div>
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<label htmlFor="document-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
<Select<PaperSize>
value={paperSize}
onValueChange={(v) => setPaperSize(v ?? "A4")}
items={PAPER_SIZES.map((s) => ({ label: `${s.label} — ${s.hint}`, value: s.value }))}
>
<SelectTrigger id="document-paper-size" className="h-10! w-48 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAPER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label} — {s.hint}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-2 sm:flex">
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!ready || busy !== null}>
<Download className="size-4" />
{busy === "download" ? "Preparing…" : "Download"}
</Button>
<Button size="lg" variant="outline" onClick={handleSend} disabled={!ready || !share || busy !== null}>
<Send className="size-4" />
{busy === "send" ? "Preparing…" : "Send"}
</Button>
<Button size="lg" onClick={() => window.print()} disabled={!ready || busy !== null}>
<Printer className="size-4" />
Print
</Button>
</div>
</div>
{isClient &&
ready &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
{renderSheet(paperSize)}
</div>,
document.body
)}
</div>
)
}
/** Popup frame for a document preview. The content remounts per open, so it starts on A4 each time. */
export function DocumentPrintDialog({
title,
open,
onOpenChange,
children,
}: {
title: string
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
<div className="border-b px-5 py-4 pr-12">
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
<DialogDescription>Preview, pick a paper size below, then download, send or print.</DialogDescription>
</div>
{open && children}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,342 @@
"use client"
import { useEffect, useState } from "react"
import { grnsApi } from "@/lib/api/grns"
import { itemsApi } from "@/lib/api/items"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import {
A4_SHEET_CLASS,
ACCENT,
DocumentPrintDialog,
DocumentPrintPreview,
HeaderRule,
PaperSize,
ReceiptRow,
ReceiptRule,
SectionTitle,
formatDate,
money,
printColorExact,
qty,
receiptSheetClass,
} from "@/components/print/DocumentPrint"
import { useAuth } from "@/components/auth/AuthProvider"
import { CompanyProfile } from "@/types/company-profile"
import { Grn, GrnLine, HoldStatus } from "@/types/grn"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { Item, Vendor, Warehouse } from "@/types/master-data"
/** Everything both layouts render, resolved once. */
interface GrnDoc {
grn: Grn
vendor: Vendor | null
warehouse: Warehouse | null
poNo: string | null
items: Map<number, Item>
company: CompanyProfile | null
showLogo: boolean
/** Print the warehouse's branch — only meaningful while warehouses are divided by branch. */
showBranch?: boolean
totals: { gross: number; discount: number; net: number; vat: number; total: number }
}
const HOLD_LABEL: Record<HoldStatus, string> = { Available: "", OnHold: "On hold", Rejected: "Rejected" }
function itemLabel(doc: GrnDoc, line: GrnLine) {
const item = doc.items.get(line.itemId)
return item ? item.name : `Item #${line.itemId}`
}
function A4Sheet(doc: GrnDoc) {
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
return (
<div className={A4_SHEET_CLASS} style={printColorExact}>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIPT NOTE</h1>
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
<dt className="text-xs font-bold text-neutral-900">GRN No.</dt>
<dd>{grn.docNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Received Date</dt>
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
<dt className="text-xs font-bold text-neutral-900">Purchase Order</dt>
<dd>{grn.poId ? (poNo ?? `PO #${grn.poId}`) : "Direct receipt"}</dd>
{grn.purchaseInvoiceNo && (
<>
<dt className="text-xs font-bold text-neutral-900">Purchase Invoice</dt>
<dd>{grn.purchaseInvoiceNo}</dd>
</>
)}
{grn.status !== "Confirmed" && (
<>
<dt className="text-xs font-bold text-neutral-900">Status</dt>
<dd className="font-semibold" style={{ color: ACCENT }}>{grn.status.toUpperCase()}</dd>
</>
)}
</dl>
</div>
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
)}
</div>
<HeaderRule />
{/* Supplier / Received at */}
<div className="grid grid-cols-2 gap-10">
<div>
<SectionTitle>Supplier</SectionTitle>
<div className="leading-snug">
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
{vendor && <p>Code: {vendor.code}</p>}
{vendor?.taxReg && <p>Tax Reg No: {vendor.taxReg}</p>}
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
</div>
</div>
<div>
<SectionTitle>Received At</SectionTitle>
<div className="leading-snug">
{company && <p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>}
<p>{warehouse ? `${warehouse.name} (${warehouse.code})` : `Warehouse #${grn.warehouseId}`}</p>
{warehouse?.address && <p>{warehouse.address}</p>}
{doc.showBranch && warehouse?.branchName && <p>Branch: {warehouse.branchName}</p>}
{warehouse?.phone && <p>{warehouse.phone}</p>}
</div>
</div>
</div>
{/* Line items */}
<div className="mt-8">
<SectionTitle>Items Received</SectionTitle>
<table className="w-full border-collapse">
<thead>
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
<th className="px-2 py-2.5 text-left">Item</th>
<th className="px-2 py-2.5 text-center">Qty</th>
<th className="px-2 py-2.5 text-right">Unit Cost</th>
<th className="px-2 py-2.5 text-right">Discount</th>
<th className="px-2 py-2.5 text-right">VAT</th>
<th className="px-2 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody>
{grn.lines.map((line) => {
const item = doc.items.get(line.itemId)
return (
<tr key={line.grnLineId} className="border-b border-neutral-300 align-top">
<td className="px-2 py-2.5">
<p>{itemLabel(doc, line)}</p>
<p className="text-xs text-neutral-500">
{[
item?.sku && `SKU ${item.sku}`,
HOLD_LABEL[line.holdStatus],
].filter(Boolean).join(" · ")}
</p>
{line.warrantyNumbers.length > 0 && (
<p className="text-xs text-neutral-500">Warranty: {line.warrantyNumbers.map((w) => w.warrantyNo).join(", ")}</p>
)}
</td>
<td className="px-2 py-2.5 text-center tabular-nums">{qty(line.qty)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitCost)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountPct > 0 ? `${qty(line.discountPct)}%` : "—"}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatAmount > 0 ? money(line.vatAmount) : "—"}</td>
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Totals */}
<div className="mt-6 flex justify-end print:break-inside-avoid">
<div className="w-72.5 border border-neutral-900">
<div className="space-y-1.5 px-3.5 py-3 text-xs">
<div className="flex justify-between font-bold text-neutral-900"><span>Gross</span><span className="tabular-nums">{money(totals.gross)}</span></div>
{totals.discount > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(totals.discount)}</span></div>}
{totals.vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(totals.vat)}</span></div>}
</div>
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
<span>Total</span>
<span className="tabular-nums">{money(totals.total)}</span>
</div>
</div>
</div>
{/* Sign-off */}
<div className="mt-16 grid grid-cols-3 gap-8 text-xs print:break-inside-avoid">
{["Received by", "Checked by", "Approved by"].map((label) => (
<div key={label}>
<div className="border-t border-neutral-900 pt-1.5 font-bold text-neutral-900">{label}</div>
</div>
))}
</div>
</div>
)
}
function ReceiptSheet({ narrow, ...doc }: GrnDoc & { narrow?: boolean }) {
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
return (
<div className={receiptSheetClass(narrow)} style={printColorExact}>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
)}
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIPT NOTE</p>
{grn.status !== "Confirmed" && <p className="font-bold">*** {grn.status.toUpperCase()} ***</p>}
</div>
<ReceiptRule />
<ReceiptRow label="GRN No." value={grn.docNo} />
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
<ReceiptRow label="PO" value={grn.poId ? (poNo ?? `#${grn.poId}`) : "Direct"} />
<ReceiptRow label="Supplier" value={vendor?.name ?? `#${grn.vendorId}`} />
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
<ReceiptRule />
<div className="flex justify-between font-bold">
<span>Item</span>
<span>Amount</span>
</div>
<ReceiptRule />
<div className="space-y-1.5">
{grn.lines.map((line) => (
<div key={line.grnLineId}>
<p className="font-medium">
{itemLabel(doc, line)}
{HOLD_LABEL[line.holdStatus] && ` (${HOLD_LABEL[line.holdStatus].toLowerCase()})`}
</p>
<div className="flex justify-between gap-2 tabular-nums">
<span>{qty(line.qty)} x {money(line.unitCost)}</span>
<span>{money(line.lineTotal)}</span>
</div>
{line.discountPct > 0 && <p className="text-right tabular-nums">Disc {qty(line.discountPct)}%</p>}
</div>
))}
</div>
<ReceiptRule />
<ReceiptRow label="Gross" value={money(totals.gross)} />
{totals.discount > 0 && <ReceiptRow label="Discount" value={`-${money(totals.discount)}`} />}
{totals.vat > 0 && <ReceiptRow label="VAT" value={money(totals.vat)} />}
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
<ReceiptRow label="TOTAL" value={money(totals.total)} bold />
</div>
<div className="mt-6 space-y-5">
<div className="border-t border-black pt-1">Received by</div>
<div className="border-t border-black pt-1">Checked by</div>
</div>
</div>
)
}
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
}
/** Loads the GRN plus its decoration (vendor, warehouse, PO no, item names, company). Only the
* GRN is required — any other failure just leaves that detail out. */
function useGrnDoc(grnId: number) {
const [doc, setDoc] = useState<GrnDoc | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(grnId) ? loadError : `Invalid GRN id '${grnId}'.`
useEffect(() => {
if (!Number.isFinite(grnId)) return
let cancelled = false
grnsApi
.get(grnId)
.then(async (grn) => {
const itemIds = [...new Set(grn.lines.map((l) => l.itemId))]
const [vendorRes, warehouse, po, itemRes, companyRes, templateRes] = await Promise.all([
vendorsApi.get(grn.vendorId).catch(() => null),
warehousesApi.get(grn.warehouseId).catch(() => null),
grn.poId ? purchaseOrdersApi.get(grn.poId).catch(() => null) : Promise.resolve(null),
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
if (cancelled) return
const company = companyRes.data
const template: InvoiceTemplateConfig | null = templateRes.data
const gross = grn.lines.reduce((s, l) => s + l.qty * l.unitCost, 0)
const net = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
setDoc({
grn,
vendor: vendorRes?.data ?? null,
warehouse,
poNo: po?.docNo ?? null,
items: new Map(itemRes.filter((i): i is Item => i !== null).map((i) => [i.itemId, i])),
company,
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
totals: { gross, discount: gross - net, net, vat, total: grn.lines.reduce((s, l) => s + l.lineTotal, 0) },
})
})
.catch((err) => {
if (!cancelled) setLoadError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [grnId])
// "Divide warehouses by branch" is always off when "Require branches" is, so this one
// flag covers both switches.
const { warehouseBranchingEnabled } = useAuth()
return { doc: doc && { ...doc, showBranch: warehouseBranchingEnabled }, error }
}
function shareText({ grn, vendor, company, totals }: GrnDoc) {
const at = company ? ` at ${company.tradeName || company.legalName}` : ""
const from = vendor ? ` from ${vendor.name}` : ""
return `Goods Receipt Note ${grn.docNo}${at} — goods received${from}, total ${money(totals.total)}.`
}
/** GRN preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
export function GrnPrintPreview({ grnId, className }: { grnId: number; className?: string }) {
const { doc, error } = useGrnDoc(grnId)
return (
<DocumentPrintPreview
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
fileName={doc?.grn.docNo ?? "grn"}
share={doc ? { title: `GRN ${doc.grn.docNo}`, text: shareText(doc) } : null}
className={className}
/>
)
}
/** Popup form of the GRN preview. */
export function GrnPrintDialog({
grnId,
docNo,
open,
onOpenChange,
}: {
grnId: number | null
docNo?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<DocumentPrintDialog title={`GRN${docNo ? ` ${docNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
{grnId !== null && <GrnPrintPreview grnId={grnId} className="flex-1" />}
</DocumentPrintDialog>
)
}
@@ -1,53 +1,33 @@
"use client"
import { useEffect, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Printer } from "lucide-react"
import { useEffect, useState } from "react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import {
A4_SHEET_CLASS,
ACCENT,
DocumentPrintDialog,
DocumentPrintPreview,
HeaderRule,
PaperSize,
ReceiptRow,
ReceiptRule,
SectionTitle,
formatDate,
money,
printColorExact,
qty,
receiptSheetClass,
} from "@/components/print/DocumentPrint"
import { CompanyProfile } from "@/types/company-profile"
import { Customer } from "@/types/customers"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
const ACCENT = "#8b1c24"
type PaperSize = "A4" | "80mm"
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
]
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
const PAGE_RULES: Record<PaperSize, string> = {
A4: "@page { size: A4; margin: 12mm; }",
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
}
function money(n: number) {
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function qty(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
/** dd/MM/yyyy, as on the reference invoice. */
function formatDate(value: string | Date) {
const d = new Date(value)
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
}
/** Everything both layouts render, resolved once. */
interface InvoiceDoc {
invoice: SalesInvoice
@@ -61,26 +41,10 @@ interface InvoiceDoc {
showBank: boolean
}
const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
/** Section heading with the short accent underline. */
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3">
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
</div>
)
}
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
const t = invoice.totals
return (
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
<div
className="invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
style={printColorExact}
>
<div className={A4_SHEET_CLASS} style={printColorExact}>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
@@ -106,11 +70,7 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
)}
</div>
{/* Header rule: heavy dark bar over a thin accent line */}
<div className="mt-5 mb-7">
<div className="h-1 bg-neutral-900" />
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
</div>
<HeaderRule />
{/* From / Bill To */}
<div className="grid grid-cols-2 gap-10">
@@ -251,28 +211,21 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
)
}
/** Dashed divider used between receipt sections. */
function ReceiptRule() {
return <div className="my-2 border-t border-dashed border-neutral-500" />
}
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
<span>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
)
}
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
/** Thermal receipt layout (80mm or 58mm roll): one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({
invoice,
customer,
payments,
company,
template,
dueDate,
notes,
showLogo,
narrow,
}: InvoiceDoc & { narrow?: boolean }) {
const t = invoice.totals
return (
<div
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
style={printColorExact}
>
<div className={receiptSheetClass(narrow)} style={printColorExact}>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
@@ -409,85 +362,31 @@ function useInvoiceDoc(invoiceId: number) {
return { doc, error }
}
const subscribeNoop = () => () => {}
/** True only on the client — the print copy is portalled into document.body. */
function useIsClient() {
return useSyncExternalStore(subscribeNoop, () => true, () => false)
}
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
}
/**
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
* just the invoice, whether this is rendered in a page or inside a dialog.
*/
function shareText({ invoice, company }: InvoiceDoc) {
const from = company ? ` from ${company.tradeName || company.legalName}` : ""
return `Invoice ${invoice.invoiceNo}${from} — total ${money(invoice.totals.netPayable)}` +
(invoice.totals.balanceAmount > 0 ? `, balance due ${money(invoice.totals.balanceAmount)}.` : ".")
}
/** Invoice preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
const { doc, error } = useInvoiceDoc(invoiceId)
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const isClient = useIsClient()
if (error && !doc) {
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
return (
<div className={cn("flex min-h-0 flex-col", className)}>
{/* Preview window — scrolls so the page keeps its true size. */}
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
{doc ? (
<Sheet size={paperSize} doc={doc} />
) : (
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
)}
</div>
{/* Paper size selection, under the invoice. */}
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<span className="text-sm font-semibold text-foreground">Paper size</span>
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
{PAPER_SIZES.map((s) => (
<button
key={s.value}
type="button"
role="radio"
aria-checked={paperSize === s.value}
onClick={() => setPaperSize(s.value)}
className={cn(
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
)}
>
<span className="text-sm font-medium">{s.label}</span>
<span className="text-xs text-muted-foreground">{s.hint}</span>
</button>
))}
</div>
</div>
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
<Printer className="size-4" />
Print {paperSize === "A4" ? "A4" : "receipt"}
</Button>
</div>
{isClient &&
doc &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
<Sheet size={paperSize} doc={doc} />
</div>,
document.body
)}
</div>
<DocumentPrintPreview
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
fileName={doc?.invoice.invoiceNo ?? "invoice"}
share={doc ? { title: `Invoice ${doc.invoice.invoiceNo}`, text: shareText(doc), phone: doc.customer?.phone } : null}
className={className}
/>
)
}
/** Popup form of the print preview. Opens on A4 every time (the content remounts per open). */
/** Popup form of the invoice preview. */
export function SalesInvoicePrintDialog({
invoiceId,
invoiceNo,
@@ -500,14 +399,8 @@ export function SalesInvoicePrintDialog({
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
<div className="border-b px-5 py-4 pr-12">
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
</div>
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
</DialogContent>
</Dialog>
<DocumentPrintDialog title={`Invoice${invoiceNo ? ` ${invoiceNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
</DocumentPrintDialog>
)
}
@@ -7,6 +7,7 @@ const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
const warning = "bg-warning/10 text-warning border-transparent"
const success = "bg-success/10 text-success border-transparent"
const destructive = "bg-destructive/10 text-destructive border-transparent"
const info = "bg-info/10 text-info border-transparent"
export function TransferStatusBadge({ status }: { status: TransferStatus }) {
const tone = status === "Received" ? success : status === "Closed" ? destructive : warning
@@ -18,7 +19,7 @@ export function TransferStatusBadge({ status }: { status: TransferStatus }) {
}
export function CountStatusBadge({ status }: { status: CountStatus }) {
const tone = status === "Posted" ? success : warning
const tone = status === "Posted" ? success : status === "Counted" ? info : warning
return (
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
{status}
+18 -2
View File
@@ -19,6 +19,12 @@ interface ComboboxProps<Value> {
emptyText?: string
disabled?: boolean
className?: string
/** Extra classes for the dropdown popup — e.g. a larger `min-w-*` so long labels fit
* when the trigger sits in a narrow table cell. */
popupClassName?: string
/** Always open below the input (never flip above it when space is short) — the list
* then scrolls within the space that is left below. */
openDownward?: boolean
"aria-invalid"?: boolean
}
@@ -32,6 +38,8 @@ export function Combobox<Value>({
emptyText = "No results found.",
disabled,
className,
popupClassName,
openDownward,
...rest
}: ComboboxProps<Value>) {
const itemToStringLabel = React.useCallback(
@@ -65,10 +73,18 @@ export function Combobox<Value>({
/>
</ComboboxPrimitive.InputGroup>
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
<ComboboxPrimitive.Positioner
side="bottom"
sideOffset={4}
collisionAvoidance={openDownward ? { side: "none", align: "shift", fallbackAxisSide: "none" } : undefined}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
className={cn(
"max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
popupClassName
)}
>
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
{emptyText}
+14 -3
View File
@@ -7,6 +7,10 @@ export interface KpiTileSpec {
label: string
value: number
icon?: LucideIcon
/** Makes the tile clickable — e.g. to filter the list below it to this metric. */
onClick?: () => void
/** Highlights the tile as the currently-applied filter. Only meaningful with `onClick`. */
active?: boolean
}
/**
@@ -17,12 +21,19 @@ export interface KpiTileSpec {
*/
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
return (
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
<div className="mb-4 flex flex-wrap gap-2">
{tiles
? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
<StatCard
key={tile.label}
label={tile.label}
value={tile.value}
icon={tile.icon}
onClick={tile.onClick}
active={tile.active}
/>
))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)}
</div>
)
}
+58 -57
View File
@@ -14,55 +14,39 @@ function formatValue(value: string | number) {
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
}
function Sparkline({ points }: { points: number[] }) {
const width = 48
const height = 18
const min = Math.min(...points)
const max = Math.max(...points)
const range = max - min || 1
const coords = points.map((point, index) => {
const x = (index / (points.length - 1)) * width
const y = height - ((point - min) / range) * height
return [x, y] as const
})
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
const [lastX, lastY] = coords[coords.length - 1]
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-4.5 w-12 shrink-0 overflow-visible"
aria-hidden="true"
>
<path d={path} fill="none" className="stroke-muted-foreground/40" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
</svg>
)
}
export interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
/** Small subtitle under the value, e.g. "All time". */
caption?: string
delta?: number
deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean
trend?: number[]
className?: string
/** Makes the card an actual button — e.g. to filter the list below it to this metric. */
onClick?: () => void
/** Highlights the card as the currently-applied filter. Only meaningful with `onClick`. */
active?: boolean
/** "sm" shrinks padding, icon and text — for tight rows of many cards (e.g. the Dashboard
* home overview) that need to fit without wrapping or scrolling. Defaults to "default". */
size?: "default" | "sm"
}
/** Icon in a circular badge on the left; label, value and an optional caption stacked to its right. */
export function StatCard({
label,
value,
icon: Icon,
caption,
delta,
deltaLabel,
invert = false,
trend,
className,
onClick,
active = false,
size = "default",
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
@@ -70,39 +54,56 @@ export function StatCard({
return (
<div
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={
onClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}
: undefined
}
className={cn(
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
"flex w-fit max-w-full shrink-0 items-center justify-self-start rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
size === "sm" ? "min-w-52 gap-3.5 p-3.5" : "min-w-64 gap-3.5 p-4",
onClick && "cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary",
active && "ring-2 ring-primary hover:ring-primary",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-xs">
<span
className={cn(
"font-semibold",
isGood ? "text-success" : "text-destructive"
)}
>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
{Icon && (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-primary/10",
size === "sm" ? "size-9" : "size-9"
)}
>
<Icon className={size === "sm" ? "size-4 text-primary" : "size-4 text-primary"} />
</div>
)}
<div className="flex min-w-0 flex-col gap-0.5">
<p className={cn("truncate font-medium text-muted-foreground", size === "sm" ? "text-sm" : "text-sm")}>{label}</p>
<p className={cn("font-bold tracking-tight text-foreground", size === "sm" ? "text-xl" : "text-xl")}>
{formatValue(value)}
</p>
{hasDelta ? (
<div className="flex items-center gap-1 text-xs">
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
) : (
caption && <p className="text-xs text-muted-foreground">{caption}</p>
)}
</div>
</div>
)
}
+43 -1
View File
@@ -1,7 +1,13 @@
// Auth endpoints (docs/11-BACKEND-PHASE1.md §2.0). ERPCore proxies the AuthHex IdP and
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
import { apiRequest } from "@/lib/api-client"
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
import {
AuthSession,
ForgotPasswordResponse,
LoginRequest,
RegisterRequest,
VerifyRecoveryOtpResponse,
} from "@/types/auth"
import { MeResponse } from "@/types/rbac"
export const authApi = {
@@ -31,6 +37,42 @@ export const authApi = {
return apiRequest<void>("/auth/logout", { method: "POST", body: { userId } })
},
/**
* Changes the signed-in user's password (also clears a pending first-login forced change).
* The server ends the session on success, so the caller must send the user back to /login.
*/
changePassword(currentPassword: string, newPassword: string): Promise<void> {
return apiRequest<void>("/auth/change-password", {
method: "POST",
body: { currentPassword, newPassword },
csrf: true,
})
},
/** Forgot password, step 1: emails a recovery OTP. Returns the reference the next steps need. */
forgotPassword(identifier: string): Promise<ForgotPasswordResponse> {
return apiRequest<ForgotPasswordResponse>("/auth/recovery/forgot-password", {
method: "POST",
body: { identifier, useResetLink: false, numberOfDigits: 6 },
})
},
/** Forgot password, step 2: checks the emailed OTP against the reference. */
verifyRecoveryOtp(referenceNumber: string, otpCode: string): Promise<VerifyRecoveryOtpResponse> {
return apiRequest<VerifyRecoveryOtpResponse>("/auth/recovery/verify-otp", {
method: "POST",
body: { referenceNumber, otpCode },
})
},
/** Forgot password, step 3: sets the new password for a verified reference. */
resetPassword(referenceNumber: string, newPassword: string, confirmPassword: string): Promise<void> {
return apiRequest<void>("/auth/recovery/reset-password", {
method: "POST",
body: { referenceNumber, newPassword, confirmPassword },
})
},
/** Exchanges the path-scoped erp_rt cookie for a fresh session. */
refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
@@ -0,0 +1,67 @@
// Carries the forgot-password flow's state (email → OTP → reset) across its three pages.
// sessionStorage keeps it per-tab and gone once the tab closes; the reference number is only
// useful together with the emailed OTP, so nothing sensitive is held here.
import { useSyncExternalStore } from "react"
const KEY = "erpcore.passwordRecovery"
export interface PasswordRecoveryState {
email: string
referenceNumber: string
/** Set once the OTP has been accepted — the reset page requires it. */
verified: boolean
}
const listeners = new Set<() => void>()
// useSyncExternalStore needs a stable snapshot, so re-parse only when the stored string changes.
let cachedRaw: string | null = null
let cachedState: PasswordRecoveryState | null = null
function readRaw(): string | null {
try {
return window.sessionStorage.getItem(KEY)
} catch {
return null
}
}
function getSnapshot(): PasswordRecoveryState | null {
const raw = readRaw()
if (raw !== cachedRaw) {
cachedRaw = raw
try {
cachedState = raw ? (JSON.parse(raw) as PasswordRecoveryState) : null
} catch {
cachedState = null
}
}
return cachedState
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
function write(raw: string | null): void {
try {
if (raw === null) window.sessionStorage.removeItem(KEY)
else window.sessionStorage.setItem(KEY, raw)
} catch {
// Storage blocked — the next page finds no state and sends the user back to step 1.
}
listeners.forEach((l) => l())
}
/** The in-progress recovery; `undefined` while server-rendering, `null` when none is in progress. */
export function useRecoveryState(): PasswordRecoveryState | null | undefined {
return useSyncExternalStore(subscribe, getSnapshot, () => undefined)
}
export function setRecoveryState(state: PasswordRecoveryState): void {
write(JSON.stringify(state))
}
export function clearRecoveryState(): void {
write(null)
}
+259 -23
View File
@@ -17,6 +17,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
"lucide-react": "^1.23.0",
"next": "^16.3.5",
"next-themes": "^0.4.6",
@@ -83,6 +85,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -533,7 +536,8 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.75.1",
@@ -726,6 +730,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -769,28 +774,6 @@
"integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
"license": "BSD-3-Clause"
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -2408,12 +2391,26 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2424,10 +2421,18 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -2479,6 +2484,7 @@
"integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
@@ -3137,6 +3143,7 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3501,6 +3508,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
@@ -3592,6 +3609,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
@@ -3707,6 +3725,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3729,6 +3767,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -3965,6 +4004,21 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -4022,6 +4076,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4098,6 +4162,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4212,6 +4277,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -4396,6 +4462,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dot-prop": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -4740,6 +4816,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4925,6 +5002,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5214,6 +5292,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5320,6 +5399,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
@@ -5345,6 +5435,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -5843,10 +5939,31 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz",
"integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -5948,6 +6065,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
@@ -6682,6 +6805,23 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7757,6 +7897,22 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7850,6 +8006,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8131,6 +8294,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -8164,6 +8337,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8209,6 +8383,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8221,6 +8396,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -8287,6 +8463,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8407,6 +8590,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -8930,6 +9123,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9253,6 +9456,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/systeminformation": {
"version": "5.31.13",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.13.tgz",
@@ -9310,6 +9523,16 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -9357,6 +9580,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9577,6 +9801,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9767,6 +9992,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
@@ -9972,6 +10207,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2
View File
@@ -18,6 +18,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
"lucide-react": "^1.23.0",
"next": "^16.3.5",
"next-themes": "^0.4.6",
+1 -1
View File
@@ -24,5 +24,5 @@ export default function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/dashboard/:path*"],
matcher: ["/dashboard/:path*", "/change-password"],
}
+13
View File
@@ -33,6 +33,19 @@ export interface LoginRequest {
deviceName?: string | null
}
export interface ForgotPasswordResponse {
/** Ties the OTP-verify and reset steps to this recovery attempt. */
referenceNumber: string | null
expiresAt: string | null
recoveryType: string | null
}
export interface VerifyRecoveryOtpResponse {
referenceNumber: string | null
userId: string | null
verified: boolean
}
export interface RegisterRequest {
roleId: string
userTypeId: string
+1
View File
@@ -13,6 +13,7 @@ export interface DashboardStats {
stockValuationTotal: number
stockValuationByWarehouse: WarehouseValuation[]
pendingApprovalPurchaseOrders: number
fullyReceivedPurchaseOrders: number
pendingGrns: number
openRequisitions: number
pendingCounts: number
+3
View File
@@ -19,6 +19,8 @@ export interface BranchConfig {
branchingEnabled: boolean
/** Independent sub-toggle: Warehouses only. Meaningless when `branchingEnabled` is off. */
warehouseBranchingEnabled: boolean
/** Independent sub-toggle: Purchase Orders only. Meaningless when `branchingEnabled` is off. */
purchaseOrderBranchingEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
@@ -26,6 +28,7 @@ export interface BranchConfig {
export interface UpdateBranchConfigRequest {
branchingEnabled: boolean
warehouseBranchingEnabled: boolean
purchaseOrderBranchingEnabled?: boolean
}
export interface Department {
+4
View File
@@ -204,6 +204,8 @@ export interface Vendor {
name: string
terms: string | null
taxReg: string | null
address: string | null
phone: string | null
currency: string
status: EntityStatus
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
@@ -217,6 +219,8 @@ export interface CreateVendorRequest {
name: string
terms?: string | null
taxReg?: string | null
address?: string | null
phone?: string | null
currency: string
/** Required when branching is enabled — resolved client-side from the logged-in user's
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
+6
View File
@@ -77,4 +77,10 @@ export interface MeResponse {
branchName: string | null
branchingEnabled: boolean
warehouseBranchingEnabled: boolean
/** Admin-created account still on its issued password — must change it before using the app. */
mustChangePassword: boolean
/** Purchase orders are divided by branch (always false when branching itself is off). */
purchaseOrderBranchingEnabled: boolean
/** The caller's local user id (matches `createdBy` on documents); null when unauthenticated. */
userId: number | null
}
+131
View File
@@ -0,0 +1,131 @@
# Sales Invoice — Print Scenarios, Sizes & Format
What a printed / downloaded / shared sales invoice looks like, per paper size and per invoice state.
Source of truth:
- Layouts: `Frontend/erp-system/components/sales/SalesInvoicePrint.tsx`
- Shared print shell (sizes, PDF, send): `Frontend/erp-system/components/print/DocumentPrint.tsx`
- Print CSS: `Frontend/erp-system/app/globals.css` (`.invoice-sheet`, `.invoice-print-root`)
- Totals / lifecycle: `Backend/ERPCore/Services/SalesInvoiceService.cs`, `SalesPostingService.cs`, `SalesInvoicePaymentService.cs`
Where it opens: **Sales → Invoices** (row action) and **Invoice detail → Print** open the print dialog; `/dashboard/sales/invoices/[id]/print` shows the same preview as a full page.
---
## 1. Paper sizes
| Size | Sheet width | Page rule (`@page`) | Base font | Use for |
|---|---|---|---|---|
| **A4** (default) | 210 mm (on screen 210 × 297 mm) | `size: A4; margin: 12mm` | 14px (`text-sm`) | Office printers, email / WhatsApp PDF, B2B / credit customers |
| **80mm** | 80 mm, padding 4 mm × 5 mm | `size: 80mm auto; margin: 3mm` | 11px | Standard thermal POS printer |
| **58mm** | 58 mm, padding 2.5 mm × 4 mm | `size: 58mm auto; margin: 2mm` | 9.5px | Small / mobile thermal printer |
- The dialog always opens on **A4**; the user switches with the **Paper size** dropdown.
- Receipt sizes have **auto height** — one continuous page as long as the content.
- A4 content longer than one page flows onto further pages; table rows and the totals / payment blocks don't split across pages (`break-inside: avoid`).
## 2. Output actions
| Action | What happens | File name |
|---|---|---|
| **Print** | Browser print dialog; only the invoice sheet prints (app UI hidden), page size set from the choice above | — |
| **Download** | Renders the previewed sheet to PDF (2× resolution). A4 → multi-page A4 PDF; receipts → one page at roll width × content height | `INV-xxxx.pdf`, `INV-xxxx-80mm.pdf`, `INV-xxxx-58mm.pdf` |
| **Send** | Builds the same PDF, then opens the device share sheet with the PDF attached. If the browser can't share files: downloads the PDF and opens WhatsApp to the customer's phone (`0XXXXXXXXX` → `94XXXXXXXXX`) with a pre-filled message; user attaches the PDF | same as Download |
Send message text: `Invoice INV-xxxx from <Company> — total Rs. x,xxx.xx, balance due Rs. x,xxx.xx.` (the balance part is omitted when fully paid).
## 3. Formatting rules (all sizes)
| Item | Format | Example |
|---|---|---|
| Money | `Rs.` + thousands separator + exactly 2 decimals | `Rs. 12,450.00` |
| Quantity | up to 3 decimals, trailing zeros dropped | `2`, `1.5`, `0.125` |
| Dates | `dd/MM/yyyy` | `29/09/2026` |
| Discounts | shown as negative | `-Rs. 250.00` |
| Zero discount / tax on a line (A4) | em dash | `—` |
| Accent colour (A4 only) | deep red `#8b1c24` for section underlines, header rule and status text | |
| Receipts | black only, no fills, logo in greyscale (thermal-safe) | |
## 4. What each layout includes
### A4
1. **Header** — large `INVOICE` title; Invoice No., Invoice Date, Due Date; **Status** row only when the invoice is not Posted. Company logo top-right (if enabled).
2. **From** — company trade name (or legal name), address, city/country, phone, email, Tax Reg No (if enabled in template).
3. **Bill To** — customer name (snapshot at invoice time), address, phone, email, customer Tax No.
4. **Item Details table** — Item · Qty · Unit Price · Discount · Tax · Amount. Free-issue lines tagged `(free issue)`; free qty shown under qty as `+ N free`.
5. **Notes / Terms** (left) — each line of the template's *Terms & Conditions* then *Footer note*.
6. **Totals box** (right) — Subtotal; Discount / Tax / Round off (only when non-zero); **Total** (net payable). If anything is paid: Paid + **Balance Due**.
7. **Payment Method** — each recorded payment: date · bank/cash account · ref · amount; otherwise "No payment recorded yet." Bank transfer details below (if enabled).
### 80mm / 58mm receipt
Single centred column, sections separated by dashed rules:
1. Logo (greyscale), company name (uppercase), address, `Tel:`, `Tax Reg:` (if enabled)
2. `INVOICE`, plus `*** DRAFT ***` / `*** CANCELLED ***` when not Posted
3. Invoice No., Date, Due, Customer, Phone
4. Items — name on one line, `qty x unit price +N free line total` on the next, `Disc -x` if discounted
5. Subtotal, Discount, Tax, Round off (non-zero only), **TOTAL** between heavy rules, Paid / **Balance** if paid
6. Payment list (date + account + amount), if any
7. Notes / terms (centred)
8. `Thank you!`
Not on receipts: customer address/email, per-line tax column, bank transfer details.
## 5. Where the data comes from
| Field | Source |
|---|---|
| Invoice no., date, lines, totals, status | Sales invoice (`GET /sales-invoices/{id}`) |
| Customer name / tax no. | **Snapshot** stored on the invoice — later customer edits don't change old invoices |
| Customer address, phone, email | Live customer record |
| Due date | Not stored — `invoice date + customer.creditDays` (0 credit days → due date = invoice date) |
| Payments | `GET /sales-invoices/{id}/payments` |
| Company name, address, logo, bank details | Settings → Company Profile |
| Logo on/off, Tax Reg No on/off, bank details on/off, terms, footer | Settings → Invoice Template |
Only the invoice itself is required. If customer, payments, company profile or template fail to load, that block is just left out. With no company profile, A4 shows a hint on screen only (hidden when printed).
## 6. Scenarios
| # | Scenario | What prints |
|---|---|---|
| 1 | **Draft** invoice | Normal layout + `Status: DRAFT` (A4, red) / `*** DRAFT ***` (receipt). Totals shown, no payments. |
| 2 | **Posted, unpaid** | No status row. Totals box ends at **Total**; "No payment recorded yet." |
| 3 | **Posted, partly paid** (installments) | Paid + **Balance Due** shown; each payment listed. Send text includes balance due. |
| 4 | **Posted, fully paid** | Paid = Total, Balance Due `Rs. 0.00`; Send text has no balance part. |
| 5 | **Cancelled** | `Status: CANCELLED` / `*** CANCELLED ***`. Only Drafts can be cancelled, so no payments. |
| 6 | Line with **discount** (% or amount) | Discount column `-Rs. x`; Discount row in totals. |
| 7 | Line with **tax** | Tax column amount (A4); Tax row in totals. |
| 8 | **Free qty** on a paid line | `+ N free` under qty. |
| 9 | **Free-issue** line (whole line free) | `(free issue)` / `(free)` after item name. |
| 10 | **Credit customer** (creditDays > 0) | Due date = invoice date + credit days. |
| 11 | **Cash customer** (creditDays = 0) | Due date = invoice date. |
| 12 | Template **logo = None**, or no logo uploaded | No logo. |
| 13 | Template **Show bank details** on + company bank set | "Bank transfer" block on A4 (bank, branch, account name/no., SWIFT). |
| 14 | Template **Show Tax Reg No** on | Company Tax Reg No under From / receipt header. |
| 15 | Terms / footer note filled | Printed line by line (blank lines skipped). |
| 16 | Customer with **no phone** | Send → WhatsApp opens contact picker instead of a chat. |
| 17 | Logo on a host without CORS | Print shows it; PDF replaces it with a blank so download still works. |
| 18 | Long invoice (many lines) | A4: continues on page 2+; receipt: longer strip. |
## 7. Totals calculation (for checking printouts)
Per invoice (`SalesInvoiceService.Recalculate`):
```
Subtotal = Σ (qty × unitPrice)
DiscountTotal= Σ line discount
TaxTotal = Σ line tax
GrandTotal = Σ lineTotal + TaxTotal
RoundOff = 0 (currently always 0 → row never shows)
NetPayable = GrandTotal + RoundOff ← printed as "Total"
Balance = NetPayable − Σ payments
```
Payment rules: only **Posted** invoices accept payments; a payment can't exceed the remaining balance. Only **Draft** invoices can be edited or cancelled.
## 8. Other invoice print route (legacy)
`/print/sales/invoices/[id]` is an older A4-only page that follows the **Invoice Template style** (Classic / Compact / Modern). It includes warehouse, SKU and UOM columns and plain 2-decimal numbers without `Rs.`. The print dialog above does **not** use the template style — only its logo / tax no. / bank / terms settings.