Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka b2646c6cb3 ui and bug fixers 29/09 2026-09-29 14:49:11 +05:30
85 changed files with 30053 additions and 813 deletions
@@ -28,14 +28,20 @@ public sealed class AuthController : ControllerBase
private readonly IAuthAltService _alt; private readonly IAuthAltService _alt;
private readonly IRoleService _roles; private readonly IRoleService _roles;
private readonly IAuditService _audit; 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; _users = users;
_recovery = recovery; _recovery = recovery;
_alt = alt; _alt = alt;
_roles = roles; _roles = roles;
_audit = audit; _audit = audit;
_userManagement = userManagement;
_currentUser = currentUser;
} }
/// <summary> /// <summary>
@@ -139,6 +145,8 @@ public sealed class AuthController : ControllerBase
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct) public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{ {
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), 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); AuthCookieWriter.ClearSession(Response);
return NoContent(); return NoContent();
} }
@@ -23,6 +23,12 @@ public class BranchConfig
/// single-branch already).</summary> /// single-branch already).</summary>
public bool WarehouseBranchingEnabled { get; set; } = true; 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 DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { get; set; }
+8
View File
@@ -31,6 +31,14 @@ public class User
/// </summary> /// </summary>
public string? Email { get; set; } 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> /// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; } public int? RoleId { get; set; }
public Role? Role { get; set; } public Role? Role { get; set; }
@@ -14,6 +14,8 @@ public class Vendor
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Terms { get; set; } public string? Terms { get; set; }
public string? TaxReg { get; set; } public string? TaxReg { get; set; }
public string? Address { get; set; }
public string? Phone { get; set; }
public string Currency { get; set; } = "LKR"; public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active; 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 /// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary> /// 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> /// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted. /// 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? BranchingEnabled { get; set; }
[Required] public bool? WarehouseBranchingEnabled { 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,17 +18,13 @@ public sealed record DashboardStatsDto(
decimal StockValuationTotal, decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse, IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders, int PendingApprovalPurchaseOrders,
int FullyReceivedPurchaseOrders,
int PendingGrns, int PendingGrns,
int OpenRequisitions, int OpenRequisitions,
int PendingCounts, int PendingCounts,
int OpenRfqs, int OpenRfqs,
int PendingLeaveRequests, int PendingLeaveRequests,
int OverdueSalesInvoices, int OverdueSalesInvoices);
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary> /// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
+7 -2
View File
@@ -8,7 +8,12 @@ namespace ERPCore.Dtos.Rbac;
/// branch pickers client-side only (no server-side branch enforcement exists yet, /// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the /// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers /// 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( public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes, 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> /// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto( 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); string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest public sealed class CreateVendorRequest
@@ -14,6 +14,8 @@ public sealed class CreateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { 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"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/> /// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary> /// 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; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { 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"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
public int BranchId { get; set; } public int BranchId { get; set; }
} }
@@ -107,11 +107,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- RecoveryManager ------------------------------------------------ // ---- RecoveryManager ------------------------------------------------
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct) // RecoveryManagerService returns its own { success, message, data } object, which the
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct); // 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) public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct); => Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct) public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
=> CallVoidAsync("recovery", "resetPassword", request, null, ct); => CallVoidAsync("recovery", "resetPassword", request, null, ct);
@@ -149,6 +152,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- Transport -------------------------------------------------------- // ---- 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) private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, 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.ConfigId).ValueGeneratedNever();
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true); 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). // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion(); 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.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50); builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).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.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status) 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<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Requisition>().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<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); modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns // 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") .HasColumnType("boolean")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("PurchaseOrderBranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -6394,6 +6399,9 @@ namespace ERPCore.Migrations
.HasMaxLength(320) .HasMaxLength(320)
.HasColumnType("character varying(320)"); .HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId") b.Property<int?>("RoleId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6427,6 +6435,7 @@ namespace ERPCore.Migrations
{ {
UserId = 1, UserId = 1,
DisplayName = "System", DisplayName = "System",
MustChangePassword = false,
Status = "Active", Status = "Active",
Username = "system" Username = "system"
}); });
@@ -6440,6 +6449,10 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId") b.Property<int>("BranchId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6463,6 +6476,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -1,25 +1,54 @@
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services.Auth; namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/> /// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : 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 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) public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct); {
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) public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct); => _authHex.VerifyRecoveryOtpAsync(request, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default) public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct); {
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) public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct); => _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.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value; config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
config.PurchaseOrderBranchingEnabled = poBranching;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
@@ -110,6 +112,25 @@ public sealed class BranchConfigService : IBranchConfigService
return await ResolveDefaultBranchIdAsync(ct); 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 // 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 // use the deployment's one default branch, creating it on first use (same placeholder
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows). // MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
@@ -125,5 +146,6 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId; 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);
} }
+5 -49
View File
@@ -26,7 +26,6 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<Rfq> _rfqs; private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<LeaveRequest> _leaveRequests; private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices; private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IReorderService _reorder; private readonly IReorderService _reorder;
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema // Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
@@ -34,14 +33,11 @@ public sealed class DashboardService : IDashboardService
// balance older than this many days from its invoice date. // balance older than this many days from its invoice date.
private const int OverdueAgeDays = 30; private const int OverdueAgeDays = 30;
// Monthly Purchases chart window: the current month plus the previous 11.
private const int ChartMonths = 12;
public DashboardService( public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs, IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices, IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder) IReorderService reorder)
{ {
_layers = layers; _layers = layers;
_grns = grns; _grns = grns;
@@ -51,7 +47,6 @@ public sealed class DashboardService : IDashboardService
_rfqs = rfqs; _rfqs = rfqs;
_leaveRequests = leaveRequests; _leaveRequests = leaveRequests;
_salesInvoices = salesInvoices; _salesInvoices = salesInvoices;
_purchaseInvoices = purchaseInvoices;
_reorder = reorder; _reorder = reorder;
} }
@@ -89,6 +84,8 @@ public sealed class DashboardService : IDashboardService
// not yet added) — these three stay global regardless of the selected branch. // not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking() var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking() var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); .CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
@@ -108,50 +105,9 @@ public sealed class DashboardService : IDashboardService
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)) var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems; .Pagination.TotalItems;
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
return new DashboardStatsDto( return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs, pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales); pendingLeaveRequests, overdueSalesInvoices);
}
/// <summary>
/// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last
/// <see cref="ChartMonths"/> months, oldest first. Months with neither are omitted, so an
/// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate,
/// sales by InvoiceDate.
/// </summary>
private async Task<IReadOnlyList<MonthlyPurchasesVsSalesDto>> GetMonthlyPurchasesVsSalesAsync(
int? branchId, IQueryable<SalesInvoice> salesInvoices, CancellationToken ct)
{
var now = DateTime.UtcNow;
var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1));
var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking();
if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId);
var purchases = await purchaseInvoices
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since)
.GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var sales = await salesInvoices
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since)
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total);
var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total);
return Enumerable.Range(0, ChartMonths)
.Select(offset => since.AddMonths(offset))
.Select(m => new MonthlyPurchasesVsSalesDto(
$"{m.Year:D4}-{m.Month:D2}",
purchasesByMonth.GetValueOrDefault((m.Year, m.Month)),
salesByMonth.GetValueOrDefault((m.Year, m.Month))))
.Where(m => m.Purchases != 0m || m.Sales != 0m)
.ToList();
} }
} }
@@ -29,6 +29,14 @@ public interface IBranchConfigService
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary> /// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default); 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 /// <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 /// 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> /// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
@@ -27,6 +27,12 @@ public interface IUserManagementService
/// </summary> /// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); 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> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default); 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) public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{ {
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); 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); await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId; 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 /// <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) 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 ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking() var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId) .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<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,13 @@ public sealed class RoleService : IRoleService
public RoleService( public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions, 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; _roles = roles;
_rolePermissions = rolePermissions; _rolePermissions = rolePermissions;
_permissions = permissions; _permissions = permissions;
_employees = employees; _employees = employees;
_users = users;
_currentUser = currentUser; _currentUser = currentUser;
_authHex = authHex; _authHex = authHex;
_branchConfig = branchConfig; _branchConfig = branchConfig;
@@ -202,6 +204,22 @@ public sealed class RoleService : IRoleService
} }
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default) public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
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, // 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 // 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(), DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(), Email = request.Email.Trim(),
RoleId = role.RoleId, 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); await _users.AddAsync(user, ct);
@@ -183,6 +185,27 @@ public sealed class UserManagementService : IUserManagementService
return Map(user); 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) public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{ {
var userTypes = await _authHex.ListUserTypesAsync(ct); var userTypes = await _authHex.ListUserTypesAsync(ct);
+7 -1
View File
@@ -62,6 +62,8 @@ public sealed class VendorService : IVendorService
Name = request.Name.Trim(), Name = request.Name.Trim(),
Terms = request.Terms, Terms = request.Terms,
TaxReg = request.TaxReg, TaxReg = request.TaxReg,
Address = Normalize(request.Address),
Phone = Normalize(request.Phone),
Currency = request.Currency.Trim().ToUpperInvariant(), Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active, Status = EntityStatus.Active,
BranchId = branchId, BranchId = branchId,
@@ -94,6 +96,8 @@ public sealed class VendorService : IVendorService
vendor.Name = request.Name.Trim(); vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms; vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg; vendor.TaxReg = request.TaxReg;
vendor.Address = Normalize(request.Address);
vendor.Phone = Normalize(request.Phone);
vendor.Currency = request.Currency.Trim().ToUpperInvariant(); vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId; vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow; vendor.UpdatedAt = DateTime.UtcNow;
@@ -120,6 +124,8 @@ public sealed class VendorService : IVendorService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static VendorDto Map(Vendor v) => new( 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 { useEffect, useMemo, useState } from "react"
import Link from "next/link" 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 { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -12,23 +12,44 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both" 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() { export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null) const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both") 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 // 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. // 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
@@ -40,6 +61,7 @@ export default function BankAccountsPage() {
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => setShowMoreLoading(false))
return () => { return () => {
cancelled = true cancelled = true
} }
@@ -49,7 +71,7 @@ export default function BankAccountsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!accounts) return null if (!accounts) return null
const q = search.trim().toLowerCase() const q = debouncedSearch.toLowerCase()
if (!q) return accounts if (!q) return accounts
return accounts.filter((a) => { return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId) const gl = glAccountsById.get(a.glAccountId)
@@ -62,116 +84,201 @@ export default function BankAccountsPage() {
(gl?.accountCode ?? "").toLowerCase().includes(q) (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 ( return (
<div className="flex flex-col gap-6"> <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"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <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> <p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div> </div>
</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" /> <Plus className="size-5" />
New Account New Account
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<div className="relative flex-1"> {stats ? (
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" /> <>
<Input <StatCard
value={search} label="Total Accounts"
onChange={(e) => setSearch(e.target.value)} value={stats.total}
placeholder="Search name, bank/type, account no. or currency…" icon={Wallet}
className="h-14 w-full pl-11 text-base" active={accountType === "Both"}
aria-label="Search cash/bank accounts" onClick={() => setAccountType("Both")}
/> />
</div> <StatCard
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}> label="Bank Accounts"
<SelectTrigger className="h-14! w-full text-base sm:w-48"> value={stats.bank}
<SelectValue /> icon={Banknote}
</SelectTrigger> active={accountType === CashBankAccountType.Bank}
<SelectContent> onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem> />
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem> <StatCard
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem> label="Cash Accounts"
</SelectContent> value={stats.cash}
</Select> 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> </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 && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && filtered === null && ( {!error && pageItems === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <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> </div>
)} )}
{!error && filtered !== null && filtered.length === 0 && ( {!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"> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Wallet className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground"> <div>
{search ? "No accounts match your search." : "No cash/bank accounts yet."} <p className="text-base font-medium text-foreground">No accounts match &quot;{debouncedSearch}&quot;</p>
</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> </div>
)} )}
{!error && filtered !== null && filtered.length > 0 && ( {!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <Wallet className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead> <p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead> <Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Currency</TableHead> New Account
<TableHead className="h-12 px-3 text-sm">Created</TableHead> </Link>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> </div>
</TableRow> )}
</TableHeader>
<TableBody> {!error && pageItems !== null && pageItems.length > 0 && (
{filtered.map((a) => { <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId) const gl = glAccountsById.get(a.glAccountId)
return ( return (
<TableRow key={`${a.accountType}-${a.accountId}`}> <div key={`${a.accountType}-${a.accountId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5"> <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 <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-6 w-16 justify-center border-transparent text-sm", "h-6 w-16 shrink-0 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
)} )}
> >
{a.accountType} {a.accountType}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell> <div>
<TableCell className="px-3 py-3.5"> <p className="text-muted-foreground">Bank / Cash type</p>
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`} <p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <div>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell> <p className="text-muted-foreground">Currency</p>
<TableCell className="px-3 py-3.5"> <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> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</TooltipTrigger> </TooltipTrigger>
@@ -180,12 +287,94 @@ export default function BankAccountsPage() {
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts. {a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> </div>
</TableRow> </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> </div>
) )
@@ -18,6 +18,7 @@ import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog" import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
import { useAuth } from "@/components/auth/AuthProvider"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = { const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground", [ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
@@ -38,6 +39,7 @@ export default function ChequeBookDetailPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null) const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const { branchingEnabled } = useAuth()
useEffect(() => { useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) 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-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p> <p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div> </div>
<div> {branchingEnabled && (
<p className="text-sm text-muted-foreground">Branch</p> <div>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p> <p className="text-sm text-muted-foreground">Branch</p>
</div> <p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
)}
<div> <div>
<p className="text-sm text-muted-foreground">Range</p> <p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium"> <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 // 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 // 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. // 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 const showBranchPicker = !lockedBranchId
useEffect(() => { useEffect(() => {
@@ -137,6 +140,7 @@ export default function NewChequeBookPage() {
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} /> <FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field> </Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}> <Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel> <FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
{showBranchPicker ? ( {showBranchPicker ? (
@@ -163,6 +167,7 @@ export default function NewChequeBookPage() {
)} )}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} /> <FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field> </Field>
)}
<Field data-invalid={!!errors.chequeBookNo}> <Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel> <FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft, 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 { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -15,152 +15,425 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = ChequeBookStatus | "All" type StatusFilter = ChequeBookStatus | "All"
interface StatusCounts {
total: number
active: number
completed: number
cancelled: number
}
const STATUS_BADGE: Record<ChequeBookStatus, string> = { const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success", [ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary", [ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive", [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() { export default function ChequeBooksPage() {
const router = useRouter() const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null) const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") 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(() => { useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, []) }, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([ Promise.all([
chequeBooksApi.list({ chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank), bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
]) ])
.then(([result, banks]) => { .then(([result, banks]) => {
if (cancelled) return if (cancelled) return
setBooks(result.items) setBooks(result.items)
setTotalItems(result.totalCount)
setBankAccounts(banks) setBankAccounts(banks)
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches. // bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, 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 bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches]) 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 ( return (
<div className="flex flex-col gap-6"> <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"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <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> <p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div> </div>
</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" /> <Plus className="size-5" />
New Cheque Book New Cheque Book
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> {statusCounts ? (
<SelectTrigger className="h-12! w-full text-base sm:w-48"> <>
<SelectValue /> <StatCard
</SelectTrigger> label="Total Cheque Books"
<SelectContent> value={statusCounts.total}
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem> icon={BookText}
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem> active={status === "All"}
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem> onClick={() => setStatus("All")}
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem> />
</SelectContent> <StatCard
</Select> 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> </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 && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && books === null && ( {!error && visibleBooks === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <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> </div>
)} )}
{!error && books !== null && books.length === 0 && ( {!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"> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<BookText className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No cheque books yet.</p> <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> </div>
)} )}
{!error && books !== null && books.length > 0 && ( {!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <BookText className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead> <p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead> {!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
<TableHead className="h-12 px-3 text-sm">Branch</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Range</TableHead> {!hasFilters && (
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead> <Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Status</TableHead> New Cheque Book
</TableRow> </Link>
</TableHeader> )}
<TableBody> </div>
{books.map((b) => { )}
{!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) const bank = bankAccountsById.get(b.bankAccountId)
return ( return (
<TableRow <div
key={b.chequeBookNo} key={b.chequeBookNo}
className="cursor-pointer" role="link"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)} 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> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5"> <p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
{bank ? bank.accountName : `#${b.bankAccountId}`} <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
</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])}>
{b.status} {b.status}
</Badge> </Badge>
</TableCell> </div>
</TableRow>
<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> </div>
) )
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react" import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -14,14 +14,27 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog" import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All" type StatusFilter = ReceivedChequeStatus | "All"
interface StatusCounts {
total: number
received: number
deposited: number
cleared: number
returned: number
cancelled: number
}
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = { const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground", [ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary", [ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
@@ -30,12 +43,27 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive", [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() { export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null) const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") 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 [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) 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([])) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, []) }, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
receivedChequesApi receivedChequesApi
.list({ .list({
page,
pageSize,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}) })
.then((res) => { .then((res) => {
if (!cancelled) setCheques(res.items) if (cancelled) return
setCheques(res.items)
setTotalItems(res.totalCount)
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true 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]) 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) { function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev)) setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated) 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 ( return (
<div className="flex flex-col gap-6"> <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"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <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> <p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div> </div>
</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" /> <Plus className="size-5" />
New Received Cheque New Received Cheque
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> {statusCounts ? (
<SelectTrigger className="h-12! w-full text-base sm:w-48"> <>
<SelectValue /> <StatCard
</SelectTrigger> label="Total Received"
<SelectContent> value={statusCounts.total}
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem> icon={Inbox}
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem> active={status === "All"}
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem> onClick={() => setStatus("All")}
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem> />
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem> <StatCard
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem> label="Received"
</SelectContent> value={statusCounts.received}
</Select> 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> </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 && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && cheques === null && ( {!error && visibleCheques === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <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> </div>
)} )}
{!error && cheques !== null && cheques.length === 0 && ( {!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"> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Inbox className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No received cheques yet.</p> <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> </div>
)} )}
{!error && cheques !== null && cheques.length > 0 && ( {!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <Inbox className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead> <p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead> {!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead> {!hasFilters && (
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead> <Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Status</TableHead> New Received Cheque
</TableRow> </Link>
</TableHeader> )}
<TableBody> </div>
{cheques.map((c) => ( )}
<TableRow
{!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} key={c.receivedChequeId}
className="cursor-pointer" role="link"
onClick={() => { tabIndex={0}
setSelected(c) onClick={(e) => {
setDialogOpen(true) 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> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell> <p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
<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])}>
{c.status} {c.status}
</Badge> </Badge>
</TableCell> </div>
</TableRow>
<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} /> <ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
+71 -21
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Eye,
HelpCircle, HelpCircle,
Inbox, Inbox,
Plus, Plus,
@@ -19,7 +20,7 @@ import { SupportTicketSummary } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" 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 { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -63,6 +64,7 @@ export default function HelpPage() {
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [subject, setSubject] = useState("") const [subject, setSubject] = useState("")
@@ -87,6 +89,12 @@ export default function HelpPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [search]) }, [search])
useEffect(() => setPage(1), [statusFilter])
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
setStatusFilter((s) => (s === status ? null : status))
}
const stats = useMemo(() => { const stats = useMemo(() => {
if (!tickets) return null if (!tickets) return null
return { return {
@@ -100,11 +108,12 @@ export default function HelpPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!tickets) return [] if (!tickets) return []
const term = debouncedSearch.toLowerCase() const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter( 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 totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -207,16 +216,40 @@ export default function HelpPage() {
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="flex flex-wrap gap-2">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} /> <StatCard
<StatCard label="Open" value={stats.open} icon={Inbox} /> label="Total tickets"
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} /> value={stats.total}
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} /> 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> </div>
@@ -264,15 +297,17 @@ export default function HelpPage() {
</div> </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"> <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" /> <Search className="size-10 text-muted-foreground" />
<div> <div>
<p className="text-base font-medium text-foreground">No tickets match &quot;{debouncedSearch}&quot;</p> <p className="text-base font-medium text-foreground">
<p className="text-sm text-muted-foreground">Try a different subject or name.</p> {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> </div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}> <Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
Clear search Clear filters
</Button> </Button>
</div> </div>
)} )}
@@ -294,11 +329,7 @@ export default function HelpPage() {
{/* Mobile: stacked cards. */} {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden"> <div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => ( {pageItems.map((t) => (
<Link <div key={t.ticketId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
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 className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p> <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])}> <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> </p>
</div> </div>
</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 && ( {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-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</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-40 px-3 text-sm">Last Updated</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -371,6 +409,18 @@ export default function HelpPage() {
day: "numeric", day: "numeric",
})} })}
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -167,13 +167,31 @@ export default function EmployeesPage() {
</Link> </Link>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="flex flex-wrap gap-2">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} /> <StatCard
<StatCard label="Active" value={stats.active} icon={UserCheck} /> 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="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" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -10,7 +10,6 @@ import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm" import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -41,8 +40,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
} }
export default function LeaveRequestsPage() { export default function LeaveRequestsPage() {
const stats = useDashboardStats()
const [requests, setRequests] = useState<LeaveRequest[] | null>(null) const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) 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 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(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
setPage(1) setPage(1)
@@ -113,7 +128,7 @@ export default function LeaveRequestsPage() {
toast.success("Leave request submitted") toast.success("Leave request submitted")
setOpen(false) setOpen(false)
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("") setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not submit leave request", errorMessage(err)) toast.error("Could not submit leave request", errorMessage(err))
} finally { } finally {
@@ -126,7 +141,7 @@ export default function LeaveRequestsPage() {
try { try {
await leaveRequestsApi.approve(id) await leaveRequestsApi.approve(id)
toast.success("Leave approved") toast.success("Leave approved")
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not approve", errorMessage(err)) toast.error("Could not approve", errorMessage(err))
} finally { } finally {
@@ -149,7 +164,7 @@ export default function LeaveRequestsPage() {
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim()) await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
toast.success("Leave rejected") toast.success("Leave rejected")
setRejectTarget(null) setRejectTarget(null)
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not reject", errorMessage(err)) toast.error("Could not reject", errorMessage(err))
} finally { } finally {
@@ -238,7 +253,49 @@ export default function LeaveRequestsPage() {
</DialogContent> </DialogContent>
</Dialog> </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="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs"> <div className="relative w-full sm:max-w-xs">
@@ -155,7 +155,7 @@ export default function PayrollRunDetailPage() {
</div> </div>
</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={Wallet} label="Gross" value={money(run.totalGross)} />
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} /> <StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} /> <StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
@@ -1,8 +1,9 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Plus } from "lucide-react" import { Eye, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll" import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -10,7 +11,7 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm" 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 { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -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">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</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 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -229,6 +231,18 @@ export default function PayrollRunsPage() {
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { useAuth } from "@/components/auth/AuthProvider"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__" const NONE = "__none__"
@@ -31,6 +32,7 @@ export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null) const [items, setItems] = useState<Department[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const { branchingEnabled } = useAuth()
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
@@ -227,18 +229,20 @@ export default function DepartmentsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field> {branchingEnabled && (
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel> <Field>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}> <FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger> <Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectContent> <SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectItem value={NONE}>None</SelectItem> <SelectContent>
{branches.map((b) => ( <SelectItem value={NONE}>None</SelectItem>
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem> {branches.map((b) => (
))} <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Field> </Select>
</Field>
)}
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button> <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> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field> {branchingEnabled && (
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel> <Field>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}> <FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger> <Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectContent> <SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectItem value={NONE}>None</SelectItem> <SelectContent>
{branches.map((b) => ( <SelectItem value={NONE}>None</SelectItem>
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem> {branches.map((b) => (
))} <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Field> </Select>
</Field>
)}
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <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>
)} )}
<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 ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <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>
<Link href="/dashboard/stock/enquiry"> <Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} /> <StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
</Link> </Link>
<Link href="/dashboard/receiving/grn"> <Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} /> <StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
</Link> </Link>
<Link href="/dashboard/procurement/requisitions"> <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>
<Link href="/dashboard/stock/counts"> <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>
<Link href="/dashboard/procurement/rfqs"> <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> </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> </div>
@@ -29,6 +29,7 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -55,7 +56,7 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
const poId = Number(params.id) const poId = Number(params.id)
const { isAdmin } = useAuth() const { isAdmin, userId } = useAuth()
const [po, setPo] = useState<PurchaseOrder | null>(null) const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [poId]) }, [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) { function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? 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. // a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived" 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -346,7 +363,7 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog> </AlertDialog>
</> </>
)} )}
{po.status === "PendingApproval" && isAdmin && ( {canDecide && (
<> <>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" /> <Check className="size-5" />
@@ -374,6 +391,13 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</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 && ( {showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5"> <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> <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 items-start gap-2">
<div className="flex-1"> <div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel> <FieldLabel className="text-sm">Item</FieldLabel>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Combobox<number>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}> items={itemOptions}
<SelectValue placeholder="Select item" /> value={line.itemId}
</SelectTrigger> onValueChange={(v) => updateLine(line.key, { itemId: v })}
<SelectContent> placeholder="Search item by SKU or name…"
{items.map((i) => ( emptyText="No items match."
<SelectItem key={i.itemId} value={i.itemId} className="text-base"> className="h-11 text-base"
{i.sku} — {i.name} openDownward
</SelectItem> aria-invalid={!!errors.itemId}
))} />
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div> </div>
<Button <Button
@@ -593,7 +615,7 @@ export default function PurchaseOrderDetailPage() {
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <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-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</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> <TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -623,18 +645,17 @@ export default function PurchaseOrderDetailPage() {
return ( return (
<TableRow key={line.key}> <TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Combobox<number>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}> items={itemOptions}
<SelectValue placeholder="Select item" /> value={line.itemId}
</SelectTrigger> onValueChange={(v) => updateLine(line.key, { itemId: v })}
<SelectContent> placeholder="Search item by SKU or name…"
{items.map((i) => ( emptyText="No items match."
<SelectItem key={i.itemId} value={i.itemId} className="text-base"> className="h-11 text-base"
{i.sku} — {i.name} popupClassName="min-w-96"
</SelectItem> openDownward
))} aria-invalid={!!errors.itemId}
</SelectContent> />
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -69,7 +70,9 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || 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 [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null) const [uoms, setUoms] = useState<Uom[] | null>(null)
@@ -92,6 +95,8 @@ function NewPurchaseOrderContent() {
const [vName, setVName] = useState("") const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("") const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("") const [vTaxReg, setVTaxReg] = useState("")
const [vAddress, setVAddress] = useState("")
const [vPhone, setVPhone] = useState("")
const [vCurrency, setVCurrency] = useState("LKR") const [vCurrency, setVCurrency] = useState("LKR")
const [vErrors, setVErrors] = useState<Record<string, string>>({}) const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false) const [vSubmitting, setVSubmitting] = useState(false)
@@ -131,6 +136,8 @@ function NewPurchaseOrderContent() {
setVName("") setVName("")
setVTerms("") setVTerms("")
setVTaxReg("") setVTaxReg("")
setVAddress("")
setVPhone("")
setVCurrency("LKR") setVCurrency("LKR")
setVErrors({}) setVErrors({})
} }
@@ -149,6 +156,8 @@ function NewPurchaseOrderContent() {
name: vName, name: vName,
terms: vTerms || null, terms: vTerms || null,
taxReg: vTaxReg || null, taxReg: vTaxReg || null,
address: vAddress || null,
phone: vPhone || null,
currency: vCurrency, currency: vCurrency,
}) })
await loadVendors() await loadVendors()
@@ -232,6 +241,14 @@ function NewPurchaseOrderContent() {
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null 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>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) 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)) 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) { function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null return items?.find((i) => i.itemId === itemId) ?? null
} }
@@ -377,6 +397,14 @@ function NewPurchaseOrderContent() {
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel> <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" /> <Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field> </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}> <Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel> <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} /> <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> </div>
) : ( ) : (
<> <>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Combobox<number>
<SelectTrigger items={itemOptions}
className="h-11! w-full text-base" value={line.itemId}
aria-invalid={!!errors.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}
title={item ? `${item.sku} — ${item.name}` : undefined} placeholder="Search item by SKU or name…"
> emptyText="No items match."
<SelectValue placeholder="Select item" /> className="h-11 text-base"
</SelectTrigger> openDownward
<SelectContent> aria-invalid={!!errors.itemId}
{(items ?? []).map((i) => ( />
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</> </>
)} )}
@@ -514,7 +536,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" /> <SelectValue placeholder="Warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []).map((w) => ( {lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.code}
</SelectItem> </SelectItem>
@@ -565,7 +587,7 @@ function NewPurchaseOrderContent() {
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <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-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</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> <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> <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 })}> <Combobox<number>
<SelectTrigger items={itemOptions}
className="h-11! w-full text-base" value={line.itemId}
aria-invalid={!!errors.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}
title={item ? `${item.sku} — ${item.name}` : undefined} placeholder="Search item by SKU or name…"
> emptyText="No items match."
<SelectValue placeholder="Select item" /> className="h-11 text-base"
</SelectTrigger> popupClassName="min-w-96"
<SelectContent> openDownward
{(items ?? []).map((i) => ( aria-invalid={!!errors.itemId}
<SelectItem key={i.itemId} value={i.itemId} className="text-base"> />
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</> </>
)} )}
@@ -615,7 +632,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" /> <SelectValue placeholder="Warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []).map((w) => ( {lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.code}
</SelectItem> </SelectItem>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { 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 { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -38,7 +38,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const router = useRouter() const router = useRouter()
const stats = useDashboardStats() 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 [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([]) 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([])) if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled]) }, [isAdmin, branchingEnabled])
function vendorOf(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)
}
function vendorCode(vendorId: number) { function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` return vendorOf(vendorId)?.code ?? `#${vendorId}`
} }
function branchName(branchId: number) { 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-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <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> <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>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
@@ -150,7 +163,28 @@ export default function PurchaseOrdersListPage() {
</div> </div>
</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"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input <Input
@@ -237,6 +271,9 @@ export default function PurchaseOrdersListPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p> <p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</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> </div>
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</div> </div>
@@ -250,6 +287,17 @@ export default function PurchaseOrdersListPage() {
<p className="text-muted-foreground">Created</p> <p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p> <p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div> </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 && ( {isAdmin && branchingEnabled && (
<div className="col-span-2"> <div className="col-span-2">
<p className="text-muted-foreground">Branch</p> <p className="text-muted-foreground">Branch</p>
@@ -339,6 +387,7 @@ export default function PurchaseOrdersListPage() {
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead> <TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead> <TableHead>Vendor</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Grand total</TableHead> <TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>} {isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
@@ -363,7 +412,22 @@ export default function PurchaseOrdersListPage() {
{po.docNo} {po.docNo}
</Link> </Link>
</TableCell> </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> <TableCell>
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</TableCell> </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> <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>
)} )}
<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> <TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link <Link
@@ -78,7 +78,21 @@ export default function RequisitionsListPage() {
</Link> </Link>
</div> </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"> <div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}> <Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -381,7 +381,7 @@ export default function ItemDetailPage() {
</div> </div>
</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={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip <StatChip
icon={BadgeDollarSign} icon={BadgeDollarSign}
@@ -502,7 +502,7 @@ export default function NewItemPage() {
{!loading && baseUomId === null && ( {!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900"> <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 —{" "} 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 create one first
</Link> </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> <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>
<div className="flex flex-wrap items-center gap-2"> <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" /> <Ruler className="size-5" />
UOMs UOMs
</Link> </Link>
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" 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 { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses" 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 { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges" import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
export default function GrnDetailPage() { export default function GrnDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
@@ -40,6 +41,7 @@ export default function GrnDetailPage() {
const [releasingLineId, setReleasingLineId] = useState<number | null>(null) const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | 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. // Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID()) const idempotencyKey = useRef(crypto.randomUUID())
@@ -155,6 +157,10 @@ export default function GrnDetailPage() {
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <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" && ( {grn.status !== "Draft" && (
<Link <Link
href="/dashboard/ledgers/general-ledger" href="/dashboard/ledgers/general-ledger"
@@ -174,6 +180,8 @@ export default function GrnDetailPage() {
</div> </div>
</div> </div>
<GrnPrintDialog grnId={grn.grnId} docNo={grn.docNo} open={printOpen} onOpenChange={setPrintOpen} />
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <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" }), itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(), uomsApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }), 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) setWarehouses(wh.items)
setItems(it.items) setItems(it.items)
setUoms(uo.items) setUoms(uo.items)
setVendors(ve.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))) .catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
@@ -631,9 +633,11 @@ export default function NewGrnPage() {
{mode === "po" && ( {mode === "po" && (
<div className="flex flex-col gap-2 sm:col-span-2"> <div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Purchase order</Label> <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"> <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> </SelectTrigger>
<SelectContent> <SelectContent>
{(purchaseOrders ?? []).map((p) => ( {(purchaseOrders ?? []).map((p) => (
@@ -643,6 +647,15 @@ export default function NewGrnPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
)} )}
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { CheckCircle2, Clock, Eye, ListChecks, PackageSearch, Plus, ReceiptText, Search } from "lucide-react"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -14,7 +13,6 @@ import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
@@ -31,6 +29,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { GrnStatusBadge } from "@/components/receiving/status-badges" import { GrnStatusBadge } from "@/components/receiving/status-badges"
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
type StatusFilter = GrnStatus | "All" 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. // re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10 const MOBILE_PAGE_SIZE_STEP = 10
interface StatusCounts {
pending: number
confirmed: number
closed: number
}
export default function GrnListPage() { export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [grns, setGrns] = useState<GrnSummary[] | null>(null) const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [counts, setCounts] = useState<StatusCounts | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -56,6 +60,7 @@ export default function GrnListPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
const [printGrn, setPrintGrn] = useState<GrnSummary | null>(null)
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
@@ -101,6 +106,24 @@ export default function GrnListPage() {
.finally(() => setShowMoreLoading(false)) .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() { function handleShowMore() {
setShowMoreLoading(true) setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
@@ -125,7 +148,35 @@ export default function GrnListPage() {
</Link> </Link>
</div> </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="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0"> <div className="relative flex-1 basis-0">
@@ -229,16 +280,22 @@ export default function GrnListPage() {
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`) setPrintGrn(grn)
}} }}
onKeyDown={(e) => { 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" 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="flex items-start justify-between gap-2">
<div className="min-w-0"> <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> <p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
</div> </div>
<GrnStatusBadge status={grn.status} /> <GrnStatusBadge status={grn.status} />
@@ -314,10 +371,10 @@ export default function GrnListPage() {
className="align-top cursor-pointer hover:bg-muted/20" className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return 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 <Link
href={`/dashboard/receiving/grn/${grn.grnId}`} href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-semibold text-foreground hover:underline" className="font-semibold text-foreground hover:underline"
@@ -352,7 +409,17 @@ export default function GrnListPage() {
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav> <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 <Link
href={`/dashboard/receiving/grn/${grn.grnId}`} href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} 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> </div>
) )
} }
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react" import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft, 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 { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
@@ -309,8 +309,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onClick={() => setPrintOpen(true)} 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")} 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" /> <ReceiptText className="size-4" />
Print Invoice
</button> </button>
</div> </div>
@@ -109,7 +109,7 @@ function NewSalesInvoiceContent() {
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId") const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
const appliedBundleRef = useRef<string | null>(null) const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0) const bundleGroupSeqRef = useRef(0)
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled, warehouseBranchingEnabled } = useAuth()
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([]) const [items, setItems] = useState<ItemListItem[]>([])
@@ -194,9 +194,15 @@ function NewSalesInvoiceContent() {
const lockedBranchId = isAdmin ? activeBranchId : myBranchId const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null 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( 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 — // Switching branch can strip the currently-picked warehouse out of the visible set —
@@ -2,8 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, ReceiptText, Tag } from "lucide-react"
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
@@ -31,6 +30,10 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" 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) { function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
@@ -70,7 +73,6 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
] ]
export default function SalesInvoicesPage() { export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -90,6 +92,7 @@ export default function SalesInvoicesPage() {
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) const [payDialogOpen, setPayDialogOpen] = useState(false)
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null) const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
const [overdueOnly, setOverdueOnly] = useState(false)
const [customerId, setCustomerId] = useState<number | undefined>(undefined) const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined) const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
@@ -164,14 +167,27 @@ export default function SalesInvoicesPage() {
) )
} }
const visibleRows = useMemo( const visibleRows = useMemo(() => {
() => const overdueSince = new Date()
rows?.filter((row) => overdueSince.setDate(overdueSince.getDate() - OVERDUE_AGE_DAYS)
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) return (
) ?? [], rows?.filter(
[rows, query] (row) =>
) `${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) &&
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined (!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 grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 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 {/* 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. */} 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 ? ( {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 ? ( {rows !== null ? (
<> <>
@@ -219,7 +241,7 @@ export default function SalesInvoicesPage() {
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} /> <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> </div>
@@ -319,16 +341,22 @@ export default function SalesInvoicesPage() {
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`) setPrintInvoice(row)
}} }}
onKeyDown={(e) => { 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" 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="flex items-start justify-between gap-2">
<div className="min-w-0"> <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> <p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1"> <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> <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)}> <Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
<Printer className="size-4" /> <ReceiptText className="size-4" />
Print Invoice
</Button> </Button>
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -425,10 +453,10 @@ export default function SalesInvoicesPage() {
className="cursor-pointer hover:bg-muted/40" className="cursor-pointer hover:bg-muted/40"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return 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"> <Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo} {row.invoiceNo}
</Link> </Link>
@@ -455,10 +483,10 @@ export default function SalesInvoicesPage() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => setPrintInvoice(row)} onClick={() => setPrintInvoice(row)}
aria-label={`Print ${row.invoiceNo}`} aria-label={`View invoice ${row.invoiceNo}`}
title="Print" title="Invoice"
> >
<Printer className="size-4" /> <ReceiptText className="size-4" />
</Button> </Button>
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { MapPin } from "lucide-react" import { MapPin } from "lucide-react"
import { branchConfigApi } from "@/lib/api/branch-config" import { branchConfigApi } from "@/lib/api/branch-config"
import { useAuth } from "@/components/auth/AuthProvider"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { BranchConfig } from "@/types/hrm" import { BranchConfig } from "@/types/hrm"
@@ -22,6 +23,9 @@ export default function BranchConfigurationPage() {
const [etag, setEtag] = useState<string | null>(null) const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) 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() { function load() {
setError(null) setError(null)
@@ -43,6 +47,7 @@ export default function BranchConfigurationPage() {
const res = await branchConfigApi.update({ branchingEnabled: next, warehouseBranchingEnabled: config.warehouseBranchingEnabled }, etag ?? "") const res = await branchConfigApi.update({ branchingEnabled: next, warehouseBranchingEnabled: config.warehouseBranchingEnabled }, etag ?? "")
setConfig(res.data) setConfig(res.data)
setEtag(res.etag) setEtag(res.etag)
refreshAuth()
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`) toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
} catch (err) { } catch (err) {
toast.error("Could not save configuration", errorMessage(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 ?? "") const res = await branchConfigApi.update({ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: next }, etag ?? "")
setConfig(res.data) setConfig(res.data)
setEtag(res.etag) setEtag(res.etag)
refreshAuth()
toast.success("Configuration saved", `Warehouse branches ${next ? "enabled" : "disabled"}.`) toast.success("Configuration saved", `Warehouse branches ${next ? "enabled" : "disabled"}.`)
} catch (err) { } catch (err) {
toast.error("Could not save configuration", errorMessage(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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -129,6 +155,25 @@ export default function BranchConfigurationPage() {
</div> </div>
</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 && ( {config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground"> <p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()} 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 { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -44,13 +44,15 @@ export default function UsersPage() {
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = 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 [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
usersApi usersApi
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined, status: statusFilter, roleCode: roleFilter })
.then((res) => { .then((res) => {
setUsers(res.items) setUsers(res.items)
setPagination(res.pagination) setPagination(res.pagination)
@@ -64,7 +66,8 @@ export default function UsersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) 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() { function loadStats() {
Promise.all([ Promise.all([
@@ -121,13 +124,37 @@ export default function UsersPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="flex flex-wrap gap-2">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total users" value={stats.total} icon={UsersIcon} /> <StatCard
<StatCard label="Active" value={stats.active} icon={UserCheck} /> label="Total users"
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> value={stats.total}
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} /> 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" />) 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 { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { 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 { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { StockCountSummary } from "@/types/stock" import { CountStatus, StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges" 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] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and // 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. // 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() { export default function CountsListPage() {
const router = useRouter() const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null) const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
@@ -49,19 +57,25 @@ export default function CountsListPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query]) useEffect(() => setPage(1), [query, status])
useEffect(() => { useEffect(() => {
setError(null) setError(null)
stockCountsApi 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) => { .then((res) => {
setCounts(res.items) setCounts(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false)) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled]) }, [page, pageSize, query, status, activeBranchId, branchingEnabled])
function handleShowMore() { function handleShowMore() {
setShowMoreLoading(true) setShowMoreLoading(true)
@@ -72,8 +86,24 @@ export default function CountsListPage() {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([])) 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 whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0 const hasFilters = query.length > 0 || status !== "All"
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -90,7 +120,35 @@ export default function CountsListPage() {
</Link> </Link>
</div> </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="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0"> <div className="relative flex-1 basis-0">
@@ -132,7 +190,7 @@ export default function CountsListPage() {
{!error && counts !== null && counts.length === 0 && ( {!error && counts !== null && counts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <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" /> <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 && ( {!hasFilters && (
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { useAuth } from "@/components/auth/AuthProvider"
interface DraftLine { interface DraftLine {
key: string key: string
@@ -52,6 +53,7 @@ export default function NewTransferPage() {
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [branchId, setBranchId] = useState<number | "All">("All") const [branchId, setBranchId] = useState<number | "All">("All")
const { branchingEnabled } = useAuth()
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null) const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null) const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
const [srcBinId, setSrcBinId] = useState<number | null>(null) const [srcBinId, setSrcBinId] = useState<number | null>(null)
@@ -117,10 +119,13 @@ export default function NewTransferPage() {
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) 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.") setSubmitError("Select a branch for this transfer.")
return return
} }
const payloadBranchId = branchingEnabled && branchId !== "All" ? branchId : 0
if (!srcWarehouseId || !destWarehouseId) { if (!srcWarehouseId || !destWarehouseId) {
setSubmitError("Select both a source and destination warehouse.") setSubmitError("Select both a source and destination warehouse.")
return return
@@ -150,7 +155,7 @@ export default function NewTransferPage() {
setSubmitting(true) setSubmitting(true)
try { 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.`) toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
router.push(`/dashboard/stock/transfers/${transfer.transferId}`) router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
} catch (err) { } catch (err) {
@@ -180,6 +185,7 @@ export default function NewTransferPage() {
{!loading && ( {!loading && (
<> <>
{branchingEnabled && (
<div className="flex flex-col gap-2 sm:max-w-xs"> <div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label> <Label className="text-base">Branch</Label>
<Select<number | "All"> <Select<number | "All">
@@ -200,6 +206,7 @@ export default function NewTransferPage() {
</Select> </Select>
<p className="text-sm text-muted-foreground">Required — the branch this transfer belongs to. Also narrows the warehouse choices below.</p> <p className="text-sm text-muted-foreground">Required — the branch this transfer belongs to. Also narrows the warehouse choices below.</p>
</div> </div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -37,7 +37,7 @@ function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; label: str
function ValuationContent() { function ValuationContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, warehouseBranchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -80,9 +80,11 @@ function ValuationContent() {
}, []) }, [])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId]) 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( const visibleWarehouses = useMemo(
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])), () => (warehouseBranchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
[warehouses, activeBranchId, branchingEnabled] [warehouses, activeBranchId, warehouseBranchingEnabled]
) )
function setParam(key: "itemId" | "warehouseId", value: number) { function setParam(key: "itemId" | "warehouseId", value: number) {
@@ -152,7 +154,7 @@ function ValuationContent() {
<Skeleton className="h-48 w-full" /> <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={Boxes} label="Item" value={item?.sku ?? `#${itemId}`} />
<StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} /> <StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} />
<StatChip icon={BadgeDollarSign} label={`Total value (${valuation.currency})`} value={valuation.totalValue.toFixed(2)} /> <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 [name, setName] = useState("")
const [terms, setTerms] = useState("") const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("") const [taxReg, setTaxReg] = useState("")
const [address, setAddress] = useState("")
const [phone, setPhone] = useState("")
const [currency, setCurrency] = useState("") const [currency, setCurrency] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
@@ -88,6 +90,8 @@ export default function VendorDetailPage() {
setName(data.name) setName(data.name)
setTerms(data.terms ?? "") setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "") setTaxReg(data.taxReg ?? "")
setAddress(data.address ?? "")
setPhone(data.phone ?? "")
setCurrency(data.currency) setCurrency(data.currency)
} }
@@ -177,7 +181,7 @@ export default function VendorDetailPage() {
if (!vendor) return if (!vendor) return
setSaving(true) setSaving(true)
try { 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) setVendor(result.data)
setEtag(result.etag) setEtag(result.etag)
setEditOpen(false) setEditOpen(false)
@@ -261,6 +265,9 @@ export default function VendorDetailPage() {
</Badge> </Badge>
</div> </div>
<p className="text-base text-muted-foreground">{vendor.name}</p> <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>
</div> </div>
@@ -281,7 +288,7 @@ export default function VendorDetailPage() {
</div> </div>
</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={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 icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip <StatChip
@@ -469,6 +476,14 @@ export default function VendorDetailPage() {
<Label className="text-base">Tax registration</Label> <Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} /> <Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div> </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"> <div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label> <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} /> <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 [name, setName] = useState("")
const [terms, setTerms] = useState("") const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("") const [taxReg, setTaxReg] = useState("")
const [address, setAddress] = useState("")
const [phone, setPhone] = useState("")
const [currency, setCurrency] = useState("LKR") const [currency, setCurrency] = useState("LKR")
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -125,6 +127,8 @@ export default function VendorsPage() {
setName("") setName("")
setTerms("") setTerms("")
setTaxReg("") setTaxReg("")
setAddress("")
setPhone("")
setCurrency("LKR") setCurrency("LKR")
setBranchFilterId(null) setBranchFilterId(null)
setErrors({}) setErrors({})
@@ -140,7 +144,7 @@ export default function VendorsPage() {
setSubmitting(true) setSubmitting(true)
try { 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}`) toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false) setOpen(false)
resetForm() resetForm()
@@ -212,6 +216,14 @@ export default function VendorsPage() {
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel> <FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" /> <Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field> </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}> <Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency">Currency</FieldLabel> <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} /> <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 { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input" import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react" 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() { export default function ForgotOtpPage() {
const router = useRouter() const router = useRouter()
const recovery = useRecoveryState()
const [otp, setOtp] = useState("") const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60) 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 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(() => { useEffect(() => {
if (secondsLeft <= 0) return if (secondsLeft <= 0) return
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000) const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, [secondsLeft]) }, [secondsLeft])
const handleResend = () => { const handleResend = async () => {
setSecondsLeft(60) if (!recovery) return
setOtp("") 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 ( return (
@@ -35,28 +81,35 @@ export default function ForgotOtpPage() {
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p> <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> <h1 className="mt-1 text-3xl font-bold text-foreground">Verify your email</h1>
<p className="mt-3 text-base text-muted-foreground"> <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> </p>
</div> </div>
<form <form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
noValidate {error && (
className="mt-10 space-y-6" <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
onSubmit={(e) => { {error}
e.preventDefault() </div>
router.push("/login/forgot/reset") )}
}} {notice && (
> <div role="status" className="rounded-lg border border-success/30 bg-success/5 p-4 text-sm text-success">
<OtpInput value={otp} onChange={(v) => setOtp(v)} /> {notice}
</div>
)}
<OtpInput length={OTP_LENGTH} value={otp} onChange={(v) => setOtp(v)} />
<div className="text-center"> <div className="text-center">
{canResend ? ( {canResend ? (
<button <button
type="button" type="button"
onClick={handleResend} 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> </button>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -65,8 +118,12 @@ export default function ForgotOtpPage() {
)} )}
</div> </div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold"> <Button
Verify 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> </Button>
<div className="text-center"> <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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations" 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() { export default function ForgotEmailPage() {
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [emailError, setEmailError] = useState<string>("") const [emailError, setEmailError] = useState<string>("")
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const onEmailChange = (value: string) => { const onEmailChange = (value: string) => {
setEmail(value) setEmail(value)
@@ -19,6 +24,28 @@ export default function ForgotEmailPage() {
setEmailError(res.valid ? "" : (res.error?.issues?.[0]?.message ?? 'Invalid email')) 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 ( return (
<AuthShell> <AuthShell>
<div className="text-center"> <div className="text-center">
@@ -29,18 +56,12 @@ export default function ForgotEmailPage() {
</p> </p>
</div> </div>
<form <form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
noValidate {submitError && (
className="mt-10 space-y-6" <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
onSubmit={(e) => { {submitError}
e.preventDefault() </div>
if (email.length === 0) { )}
setEmailError("Email is required")
return
}
if (emailError === "") router.push("/login/forgot/otp")
}}
>
<div> <div>
<label htmlFor="email" className="block text-sm font-semibold text-foreground">Email Address</label> <label htmlFor="email" className="block text-sm font-semibold text-foreground">Email Address</label>
<Input <Input
@@ -56,8 +77,8 @@ export default function ForgotEmailPage() {
{emailError !== "" && <p className="mt-2 text-sm font-medium text-destructive">{emailError}</p>} {emailError !== "" && <p className="mt-2 text-sm font-medium text-destructive">{emailError}</p>}
</div> </div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== ""}> <Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== "" || submitting}>
Send verification code {submitting ? "Sending…" : "Send verification code"}
</Button> </Button>
<div className="text-center"> <div className="text-center">
@@ -2,7 +2,10 @@
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" 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 { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -33,6 +36,9 @@ export default function ForgotResetPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const [showSuccess, setShowSuccess] = 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 meetsLength = password.length >= 8 && password.length <= 20
const hasUpper = /[A-Z]/.test(password) const hasUpper = /[A-Z]/.test(password)
@@ -41,6 +47,28 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0 const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch 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 ( return (
<AuthShell> <AuthShell>
<div className="text-center"> <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> <p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div> </div>
<form <form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
noValidate {submitError && (
className="mt-10 space-y-6" <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
onSubmit={(e) => { {submitError}
e.preventDefault() </div>
if (allValid) setShowSuccess(true) )}
}}
>
<div> <div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label> <label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative mt-2"> <div className="relative mt-2">
@@ -115,8 +141,8 @@ export default function ForgotResetPage() {
<PasswordRequirement met={hasSpecial} label="At least 1 special character" /> <PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div> </div>
<Button type="submit" disabled={!allValid} className="mt-8 h-12 w-full rounded-full text-base font-semibold"> <Button type="submit" disabled={!allValid || submitting || !recovery} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
Change password {submitting ? "Saving…" : "Change password"}
</Button> </Button>
<div className="text-center"> <div className="text-center">
@@ -126,7 +152,7 @@ export default function ForgotResetPage() {
</div> </div>
</form> </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"> <DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center"> <DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4"> <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 [remember, setRemember] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const returnTo = searchParams.get("next") 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>({ const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
@@ -45,6 +49,12 @@ function LoginForm() {
// The session arrives as httpOnly cookies; the body carries only the user profile. // The session arrives as httpOnly cookies; the body carries only the user profile.
const session = await authApi.login({ identifier: values.email, password: values.password }) const session = await authApi.login({ identifier: values.email, password: values.password })
setStoredUser(session.user) 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 // Honour the path the guard bounced us from, but only same-origin paths — an
// attacker-supplied absolute URL here would be an open redirect. // attacker-supplied absolute URL here would be an open redirect.
const next = searchParams.get("next") const next = searchParams.get("next")
@@ -43,7 +43,6 @@ import {
PieChart, PieChart,
Receipt, Receipt,
ReceiptText, ReceiptText,
Ruler,
Scale, Scale,
ScrollText, ScrollText,
Settings, Settings,
@@ -98,8 +97,9 @@ const navItems: {
children: [ children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes }, { title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree }, { title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag }, // One entry for the merged Brand/UOM tab page (the old /uoms route just redirects to
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler }, // 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 }, { 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 pathname = usePathname() || "/"
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = 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 // 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 // flashing the full menu to a restricted role. Once resolved, a nav item
@@ -463,9 +465,10 @@ export function AppSidebar() {
) )
.map((item) => ({ .map((item) => ({
...item, ...item,
children: bypassCodes.has(item.code) children: (bypassCodes.has(item.code)
? item.children ? 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 // Close mobile menu on route change
@@ -5,6 +5,7 @@
// client-only `roleId` cached by lib/auth-session.ts for anything access-related // 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). // (that cache remains display-only, e.g. for the header's user name).
import { createContext, useContext, useEffect, useState } from "react" import { createContext, useContext, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { authApi } from "@/lib/api/auth" import { authApi } from "@/lib/api/auth"
import { MeResponse } from "@/types/rbac" import { MeResponse } from "@/types/rbac"
@@ -32,6 +33,10 @@ function readStoredActiveBranch(): ActiveBranch | null {
} }
interface AuthContextValue { 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 roleCode: string | null
roleName: string | null roleName: string | null
navCodes: string[] navCodes: string[]
@@ -62,11 +67,19 @@ interface AuthContextValue {
* everything else. Always false when `branchingEnabled` itself is false. * everything else. Always false when `branchingEnabled` itself is false.
*/ */
warehouseBranchingEnabled: boolean 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. */ /** True until the first `/auth/me` response lands. */
loading: boolean loading: boolean
} }
const AuthContext = createContext<AuthContextValue>({ const AuthContext = createContext<AuthContextValue>({
refresh: () => {},
userId: null,
roleCode: null, roleCode: null,
roleName: null, roleName: null,
navCodes: [], navCodes: [],
@@ -78,12 +91,17 @@ const AuthContext = createContext<AuthContextValue>({
setActiveBranch: () => {}, setActiveBranch: () => {},
branchingEnabled: true, branchingEnabled: true,
warehouseBranchingEnabled: true, warehouseBranchingEnabled: true,
purchaseOrderBranchingEnabled: true,
loading: true, loading: true,
}) })
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<MeResponse | null>(null) const [me, setMe] = useState<MeResponse | null>(null)
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -95,12 +113,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.catch(() => { .catch(() => {
// Unauthenticated/unreachable — fall back to "no permissions" rather than // Unauthenticated/unreachable — fall back to "no permissions" rather than
// crash the shell; proxy.ts already redirects unauthenticated users to /login. // crash the shell; proxy.ts already redirects unauthenticated users to /login.
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], 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 () => { return () => {
cancelled = true 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(() => { useEffect(() => {
if (me === null) return 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)) 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 ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
refresh: () => setReloadKey((k) => k + 1),
userId: me?.userId ?? null,
roleCode: me?.roleCode ?? null, roleCode: me?.roleCode ?? null,
roleName: me?.roleName ?? null, roleName: me?.roleName ?? null,
navCodes: me?.navCodes ?? [], navCodes: me?.navCodes ?? [],
isAdmin: me?.isAdmin ?? false, isAdmin: me?.isAdmin ?? false,
branchId: me?.branchId ?? null, branchId: me?.branchId ?? null,
branchName: me?.branchName ?? null, branchName: me?.branchName ?? null,
activeBranchId: activeBranch?.id ?? null, activeBranchId: branchingEnabled ? (activeBranch?.id ?? null) : null,
activeBranchName: activeBranch?.name ?? null, activeBranchName: branchingEnabled ? (activeBranch?.name ?? null) : null,
setActiveBranch, setActiveBranch,
branchingEnabled: me?.branchingEnabled ?? true, branchingEnabled,
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true, 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} {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" "use client"
import { useEffect, useState, useSyncExternalStore } from "react" import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
import { companyProfileApi } from "@/lib/api/company-profile" import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import {
import { Button } from "@/components/ui/button" A4_SHEET_CLASS,
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" ACCENT,
import { Skeleton } from "@/components/ui/skeleton" DocumentPrintDialog,
DocumentPrintPreview,
HeaderRule,
PaperSize,
ReceiptRow,
ReceiptRule,
SectionTitle,
formatDate,
money,
printColorExact,
qty,
receiptSheetClass,
} from "@/components/print/DocumentPrint"
import { CompanyProfile } from "@/types/company-profile" import { CompanyProfile } from "@/types/company-profile"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { InvoiceTemplateConfig } from "@/types/invoice-template" import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales" 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. */ /** Everything both layouts render, resolved once. */
interface InvoiceDoc { interface InvoiceDoc {
invoice: SalesInvoice invoice: SalesInvoice
@@ -61,26 +41,10 @@ interface InvoiceDoc {
showBank: boolean 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) { function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
const t = invoice.totals const t = invoice.totals
return ( return (
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing. <div className={A4_SHEET_CLASS} style={printColorExact}>
<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}
>
{/* Title + meta */} {/* Title + meta */}
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div> <div>
@@ -106,11 +70,7 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
)} )}
</div> </div>
{/* Header rule: heavy dark bar over a thin accent line */} <HeaderRule />
<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>
{/* From / Bill To */} {/* From / Bill To */}
<div className="grid grid-cols-2 gap-10"> <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. */ /** Thermal receipt layout (80mm or 58mm roll): one centred column, no fills (thermal heads print black only). */
function ReceiptRule() { function ReceiptSheet({
return <div className="my-2 border-t border-dashed border-neutral-500" /> invoice,
} customer,
payments,
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { company,
return ( template,
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}> dueDate,
<span>{label}</span> notes,
<span className="tabular-nums">{value}</span> showLogo,
</div> narrow,
) }: InvoiceDoc & { narrow?: boolean }) {
}
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
const t = invoice.totals const t = invoice.totals
return ( return (
<div <div className={receiptSheetClass(narrow)} style={printColorExact}>
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="text-center"> <div className="text-center">
{showLogo && ( {showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed // 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 } 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 }) { 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"} />
} }
/** function shareText({ invoice, company }: InvoiceDoc) {
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet const from = company ? ` from ${company.tradeName || company.legalName}` : ""
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is return `Invoice ${invoice.invoiceNo}${from} — total ${money(invoice.totals.netPayable)}` +
* just the invoice, whether this is rendered in a page or inside a dialog. (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 }) { export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
const { doc, error } = useInvoiceDoc(invoiceId) 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 ( return (
<div className={cn("flex min-h-0 flex-col", className)}> <DocumentPrintPreview
{/* Preview window — scrolls so the page keeps its true size. */} renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6"> error={error}
{doc ? ( fileName={doc?.invoice.invoiceNo ?? "invoice"}
<Sheet size={paperSize} doc={doc} /> share={doc ? { title: `Invoice ${doc.invoice.invoiceNo}`, text: shareText(doc), phone: doc.customer?.phone } : null}
) : ( className={className}
<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>
) )
} }
/** 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({ export function SalesInvoicePrintDialog({
invoiceId, invoiceId,
invoiceNo, invoiceNo,
@@ -500,14 +399,8 @@ export function SalesInvoicePrintDialog({
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
}) { }) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <DocumentPrintDialog title={`Invoice${invoiceNo ? ` ${invoiceNo}` : ""}`} 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))]"> {invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
<div className="border-b px-5 py-4 pr-12"> </DocumentPrintDialog>
<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>
) )
} }
@@ -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 warning = "bg-warning/10 text-warning border-transparent"
const success = "bg-success/10 text-success border-transparent" const success = "bg-success/10 text-success border-transparent"
const destructive = "bg-destructive/10 text-destructive 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 }) { export function TransferStatusBadge({ status }: { status: TransferStatus }) {
const tone = status === "Received" ? success : status === "Closed" ? destructive : warning 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 }) { export function CountStatusBadge({ status }: { status: CountStatus }) {
const tone = status === "Posted" ? success : warning const tone = status === "Posted" ? success : status === "Counted" ? info : warning
return ( return (
<Badge variant="outline" className={`${badgeSize} ${tone}`}> <Badge variant="outline" className={`${badgeSize} ${tone}`}>
{status} {status}
+18 -2
View File
@@ -19,6 +19,12 @@ interface ComboboxProps<Value> {
emptyText?: string emptyText?: string
disabled?: boolean disabled?: boolean
className?: string 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 "aria-invalid"?: boolean
} }
@@ -32,6 +38,8 @@ export function Combobox<Value>({
emptyText = "No results found.", emptyText = "No results found.",
disabled, disabled,
className, className,
popupClassName,
openDownward,
...rest ...rest
}: ComboboxProps<Value>) { }: ComboboxProps<Value>) {
const itemToStringLabel = React.useCallback( const itemToStringLabel = React.useCallback(
@@ -65,10 +73,18 @@ export function Combobox<Value>({
/> />
</ComboboxPrimitive.InputGroup> </ComboboxPrimitive.InputGroup>
<ComboboxPrimitive.Portal> <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 <ComboboxPrimitive.Popup
data-slot="combobox-content" 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"> <ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
{emptyText} {emptyText}
+14 -3
View File
@@ -7,6 +7,10 @@ export interface KpiTileSpec {
label: string label: string
value: number value: number
icon?: LucideIcon 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 }) { export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
return ( 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
? tiles.map((tile) => ( ? 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> </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()) 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 { export interface StatCardProps {
label: string label: string
value: string | number value: string | number
icon?: LucideIcon icon?: LucideIcon
/** Small subtitle under the value, e.g. "All time". */
caption?: string
delta?: number delta?: number
deltaLabel?: string deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */ /** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean invert?: boolean
trend?: number[]
className?: string 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({ export function StatCard({
label, label,
value, value,
icon: Icon, icon: Icon,
caption,
delta, delta,
deltaLabel, deltaLabel,
invert = false, invert = false,
trend,
className, className,
onClick,
active = false,
size = "default",
}: StatCardProps) { }: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0 const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0 const isPositive = hasDelta && delta > 0
@@ -70,39 +54,56 @@ export function StatCard({
return ( return (
<div <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( 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 className
)} )}
> >
<div className="flex items-start justify-between gap-2"> {Icon && (
<p className="text-sm font-medium text-muted-foreground">{label}</p> <div
{Icon && ( className={cn(
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10"> "flex shrink-0 items-center justify-center rounded-full bg-primary/10",
<Icon className="size-3.5 text-primary" /> size === "sm" ? "size-9" : "size-9"
</div> )}
)} >
</div> <Icon className={size === "sm" ? "size-4 text-primary" : "size-4 text-primary"} />
<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>}
</div> </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> </div>
) )
} }
+43 -1
View File
@@ -1,7 +1,13 @@
// Auth endpoints (docs/11-BACKEND-PHASE1.md §2.0). ERPCore proxies the AuthHex IdP and // 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. // delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
import { apiRequest } from "@/lib/api-client" 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" import { MeResponse } from "@/types/rbac"
export const authApi = { export const authApi = {
@@ -31,6 +37,42 @@ export const authApi = {
return apiRequest<void>("/auth/logout", { method: "POST", body: { userId } }) 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. */ /** Exchanges the path-scoped erp_rt cookie for a fresh session. */
refresh(): Promise<AuthSession> { refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} }) 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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"next": "^16.3.5", "next": "^16.3.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -83,6 +85,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -533,7 +536,8 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.75.1", "version": "1.75.1",
@@ -726,6 +730,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -769,28 +774,6 @@
"integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
"license": "BSD-3-Clause" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -2408,12 +2391,26 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2424,10 +2421,18 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@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": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "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==", "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0", "@typescript-eslint/types": "8.63.0",
@@ -3137,6 +3143,7 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3501,6 +3508,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.25", "version": "2.11.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
@@ -3592,6 +3609,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.23", "baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810", "caniuse-lite": "^1.0.30001810",
@@ -3707,6 +3725,26 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -3965,6 +4004,21 @@
"node": ">=6.6.0" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -4022,6 +4076,16 @@
"node": ">= 8" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -4212,6 +4277,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -4396,6 +4462,16 @@
"node": ">=0.10.0" "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": { "node_modules/dot-prop": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -4740,6 +4816,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4925,6 +5002,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -5214,6 +5292,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -5320,6 +5399,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-uri": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
@@ -5345,6 +5435,12 @@
"reusify": "^1.0.4" "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": { "node_modules/figures": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz",
"integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -5948,6 +6065,12 @@
"node": ">= 0.4" "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": { "node_modules/ip-address": {
"version": "10.7.2", "version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
@@ -6682,6 +6805,23 @@
"graceful-fs": "^4.1.6" "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": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7757,6 +7897,22 @@
"node": ">=6" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7850,6 +8006,13 @@
"url": "https://opencollective.com/express" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8131,6 +8294,16 @@
], ],
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -8209,6 +8383,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -8221,6 +8396,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -8287,6 +8463,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8407,6 +8590,16 @@
"node": ">=0.10.0" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -8930,6 +9123,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9253,6 +9456,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/systeminformation": {
"version": "5.31.13", "version": "5.31.13",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.13.tgz", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.13.tgz",
@@ -9310,6 +9523,16 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -9357,6 +9580,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -9577,6 +9801,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9767,6 +9992,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/validate-npm-package-name": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+2
View File
@@ -18,6 +18,8 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"next": "^16.3.5", "next": "^16.3.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+1 -1
View File
@@ -24,5 +24,5 @@ export default function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/dashboard/:path*"], matcher: ["/dashboard/:path*", "/change-password"],
} }
+13
View File
@@ -33,6 +33,19 @@ export interface LoginRequest {
deviceName?: string | null 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 { export interface RegisterRequest {
roleId: string roleId: string
userTypeId: string userTypeId: string
+1 -9
View File
@@ -13,19 +13,11 @@ export interface DashboardStats {
stockValuationTotal: number stockValuationTotal: number
stockValuationByWarehouse: WarehouseValuation[] stockValuationByWarehouse: WarehouseValuation[]
pendingApprovalPurchaseOrders: number pendingApprovalPurchaseOrders: number
fullyReceivedPurchaseOrders: number
pendingGrns: number pendingGrns: number
openRequisitions: number openRequisitions: number
pendingCounts: number pendingCounts: number
openRfqs: number openRfqs: number
pendingLeaveRequests: number pendingLeaveRequests: number
overdueSalesInvoices: number overdueSalesInvoices: number
monthlyPurchasesVsSales: MonthlyPurchasesVsSales[]
}
// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM".
// Only months with activity are returned (last 12, oldest first).
export interface MonthlyPurchasesVsSales {
month: string
purchases: number
sales: number
} }
+3
View File
@@ -19,6 +19,8 @@ export interface BranchConfig {
branchingEnabled: boolean branchingEnabled: boolean
/** Independent sub-toggle: Warehouses only. Meaningless when `branchingEnabled` is off. */ /** Independent sub-toggle: Warehouses only. Meaningless when `branchingEnabled` is off. */
warehouseBranchingEnabled: boolean warehouseBranchingEnabled: boolean
/** Independent sub-toggle: Purchase Orders only. Meaningless when `branchingEnabled` is off. */
purchaseOrderBranchingEnabled: boolean
updatedAt: string | null updatedAt: string | null
updatedBy: number | null updatedBy: number | null
} }
@@ -26,6 +28,7 @@ export interface BranchConfig {
export interface UpdateBranchConfigRequest { export interface UpdateBranchConfigRequest {
branchingEnabled: boolean branchingEnabled: boolean
warehouseBranchingEnabled: boolean warehouseBranchingEnabled: boolean
purchaseOrderBranchingEnabled?: boolean
} }
export interface Department { export interface Department {
+4
View File
@@ -204,6 +204,8 @@ export interface Vendor {
name: string name: string
terms: string | null terms: string | null
taxReg: string | null taxReg: string | null
address: string | null
phone: string | null
currency: string currency: string
status: EntityStatus status: EntityStatus
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */ /** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
@@ -217,6 +219,8 @@ export interface CreateVendorRequest {
name: string name: string
terms?: string | null terms?: string | null
taxReg?: string | null taxReg?: string | null
address?: string | null
phone?: string | null
currency: string currency: string
/** Required when branching is enabled — resolved client-side from the logged-in user's /** 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()`). */ * 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 branchName: string | null
branchingEnabled: boolean branchingEnabled: boolean
warehouseBranchingEnabled: 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.