Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2646c6cb3 | |||
| d24072e20a | |||
| 9185d39c39 | |||
| a2790c13bb | |||
| 940583552a | |||
| f35439e364 |
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
[FromQuery] int? branchId,
|
[FromQuery] int? branchId,
|
||||||
|
[FromQuery] DateOnly? from,
|
||||||
|
[FromQuery] DateOnly? to,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,6 +18,7 @@ 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,
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
+8749
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8757
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8762
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,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);
|
||||||
@@ -105,7 +107,7 @@ public sealed class DashboardService : IDashboardService
|
|||||||
|
|
||||||
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);
|
pendingLeaveRequests, overdueSalesInvoices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
|
|
||||||
public interface ISalesInvoiceService
|
public interface ISalesInvoiceService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
|
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
|
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -64,6 +64,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
||||||
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
||||||
|
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||||
|
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||||
|
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'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,111 +84,263 @@ 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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
<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")}
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Bank Accounts"
|
||||||
|
value={stats.bank}
|
||||||
|
icon={Banknote}
|
||||||
|
active={accountType === CashBankAccountType.Bank}
|
||||||
|
onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Cash Accounts"
|
||||||
|
value={stats.cash}
|
||||||
|
icon={Wallet}
|
||||||
|
active={accountType === CashBankAccountType.Cash}
|
||||||
|
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||||
|
)}
|
||||||
</div>
|
</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")}>
|
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||||
<SelectTrigger className="h-14! w-full text-base sm:w-48">
|
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
|
<SelectItem value="Both">All types</SelectItem>
|
||||||
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
|
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
|
||||||
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
|
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 "{debouncedSearch}"</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 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<Wallet className="size-12 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && pageItems !== null && pageItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Mobile: stacked cards. */}
|
||||||
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
|
{pageItems.map((a) => {
|
||||||
|
const gl = glAccountsById.get(a.glAccountId)
|
||||||
|
return (
|
||||||
|
<div key={`${a.accountType}-${a.accountId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-foreground">{a.accountName}</p>
|
||||||
|
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-16 shrink-0 justify-center border-transparent text-sm",
|
||||||
|
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.accountType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Bank / Cash type</p>
|
||||||
|
<p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Currency</p>
|
||||||
|
<p className="font-medium text-foreground">{a.currencyCode}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">GL account</p>
|
||||||
|
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Created</p>
|
||||||
|
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||||
|
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{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">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
|
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((a) => {
|
{pageItems.map((a) => {
|
||||||
const gl = glAccountsById.get(a.glAccountId)
|
const gl = glAccountsById.get(a.glAccountId)
|
||||||
return (
|
return (
|
||||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-16 justify-center border-transparent text-sm",
|
"h-6 w-16 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>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -180,12 +354,27 @@ export default function BankAccountsPage() {
|
|||||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
|
{branchingEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Branch</p>
|
<p className="text-sm text-muted-foreground">Branch</p>
|
||||||
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Range</p>
|
<p className="text-sm text-muted-foreground">Range</p>
|
||||||
<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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
<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's own supply.</p>
|
<p className="text-base text-muted-foreground">Cheque books issued from this company'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">
|
||||||
|
{statusCounts ? (
|
||||||
|
<>
|
||||||
|
<StatCard
|
||||||
|
label="Total Cheque Books"
|
||||||
|
value={statusCounts.total}
|
||||||
|
icon={BookText}
|
||||||
|
active={status === "All"}
|
||||||
|
onClick={() => setStatus("All")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active"
|
||||||
|
value={statusCounts.active}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
active={status === ChequeBookStatus.Active}
|
||||||
|
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Completed"
|
||||||
|
value={statusCounts.completed}
|
||||||
|
icon={BookCheck}
|
||||||
|
active={status === ChequeBookStatus.Completed}
|
||||||
|
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Cancelled"
|
||||||
|
value={statusCounts.cancelled}
|
||||||
|
icon={BookX}
|
||||||
|
active={status === ChequeBookStatus.Cancelled}
|
||||||
|
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Search cheque book no., bank or branch…"
|
||||||
|
advancedActive={status !== "All"}
|
||||||
|
onClearAdvanced={() => setStatus("All")}
|
||||||
|
advanced={
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
<SelectItem value="All">All statuses</SelectItem>
|
||||||
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
|
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
|
||||||
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
|
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
|
||||||
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 "{debouncedSearch}"</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 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<BookText className="size-12 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
|
||||||
|
{!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
|
||||||
|
</div>
|
||||||
|
{!hasFilters && (
|
||||||
|
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Cheque Book
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Mobile: stacked cards. */}
|
||||||
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
|
{visibleBooks.map((b) => {
|
||||||
|
const bank = bankAccountsById.get(b.bankAccountId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.chequeBookNo}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||||
|
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
|
||||||
|
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Bank account</p>
|
||||||
|
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
|
||||||
|
</div>
|
||||||
|
{branchingEnabled && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Branch</p>
|
||||||
|
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Range</p>
|
||||||
|
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Leaves</p>
|
||||||
|
<p className="font-medium text-foreground">{b.totalLeaves}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">Received</p>
|
||||||
|
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View ${b.chequeBookNo}`}
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{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">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{books.map((b) => {
|
{visibleBooks.map((b) => {
|
||||||
const bank = bankAccountsById.get(b.bankAccountId)
|
const bank = bankAccountsById.get(b.bankAccountId)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={b.chequeBookNo}
|
key={b.chequeBookNo}
|
||||||
className="cursor-pointer"
|
className="align-top cursor-pointer hover:bg-muted/20"
|
||||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
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-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||||
<TableCell className="px-3 py-3.5">
|
<Link
|
||||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||||
|
className="font-semibold text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{b.chequeBookNo}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
{branchingEnabled && (
|
||||||
{b.startChequeNo} – {b.endChequeNo}
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
<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}
|
{b.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
<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">
|
||||||
|
{statusCounts ? (
|
||||||
|
<>
|
||||||
|
<StatCard
|
||||||
|
label="Total Received"
|
||||||
|
value={statusCounts.total}
|
||||||
|
icon={Inbox}
|
||||||
|
active={status === "All"}
|
||||||
|
onClick={() => setStatus("All")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Received"
|
||||||
|
value={statusCounts.received}
|
||||||
|
icon={Wallet}
|
||||||
|
active={status === ReceivedChequeStatus.Received}
|
||||||
|
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Deposited"
|
||||||
|
value={statusCounts.deposited}
|
||||||
|
icon={Banknote}
|
||||||
|
active={status === ReceivedChequeStatus.Deposited}
|
||||||
|
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Cleared"
|
||||||
|
value={statusCounts.cleared}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
active={status === ReceivedChequeStatus.Cleared}
|
||||||
|
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Returned"
|
||||||
|
value={statusCounts.returned}
|
||||||
|
icon={Undo2}
|
||||||
|
active={status === ReceivedChequeStatus.Returned}
|
||||||
|
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
|
||||||
|
advancedActive={status !== "All"}
|
||||||
|
onClearAdvanced={() => setStatus("All")}
|
||||||
|
advanced={
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
<SelectItem value="All">All statuses</SelectItem>
|
||||||
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
|
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
|
||||||
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
|
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
|
||||||
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
|
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
|
||||||
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
|
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
|
||||||
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 "{debouncedSearch}"</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 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<Inbox className="size-12 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
|
||||||
|
{!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
|
||||||
|
</div>
|
||||||
|
{!hasFilters && (
|
||||||
|
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Received Cheque
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Mobile: stacked cards. */}
|
||||||
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
|
{visibleCheques.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.receivedChequeId}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||||
|
openDialog(c)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") openDialog(c)
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
|
||||||
|
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Received from</p>
|
||||||
|
<p className="font-medium text-foreground">{c.receivedFromName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Type</p>
|
||||||
|
<p className="font-medium text-foreground">{c.receivedFromType}</p>
|
||||||
|
</div>
|
||||||
|
{branchingEnabled && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Branch</p>
|
||||||
|
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Amount</p>
|
||||||
|
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">Received</p>
|
||||||
|
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`View cheque ${c.chequeNo}`}
|
||||||
|
title="View"
|
||||||
|
onClick={() => openDialog(c)}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{cheques.map((c) => (
|
{visibleCheques.map((c) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={c.receivedChequeId}
|
key={c.receivedChequeId}
|
||||||
className="cursor-pointer"
|
className="align-top cursor-pointer hover:bg-muted/20"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
setSelected(c)
|
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||||
setDialogOpen(true)
|
openDialog(c)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
{branchingEnabled && (
|
||||||
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||||
</TableCell>
|
</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-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
<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}
|
{c.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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} />
|
||||||
|
|||||||
@@ -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 "{debouncedSearch}"</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 "{debouncedSearch}"</> : `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>
|
||||||
|
<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>
|
</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,6 +229,7 @@ export default function DepartmentsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{branchingEnabled && (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
||||||
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
||||||
@@ -239,6 +242,7 @@ export default function DepartmentsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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,6 +286,7 @@ export default function DepartmentsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{branchingEnabled && (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||||
@@ -294,6 +299,7 @@ export default function DepartmentsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||||
|
placeholder="Search item by SKU or name…"
|
||||||
|
emptyText="No items match."
|
||||||
|
className="h-11 text-base"
|
||||||
|
openDownward
|
||||||
aria-invalid={!!errors.itemId}
|
aria-invalid={!!errors.itemId}
|
||||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
/>
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<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}
|
||||||
|
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||||
|
placeholder="Search item by SKU or name…"
|
||||||
|
emptyText="No items match."
|
||||||
|
className="h-11 text-base"
|
||||||
|
popupClassName="min-w-96"
|
||||||
|
openDownward
|
||||||
aria-invalid={!!errors.itemId}
|
aria-invalid={!!errors.itemId}
|
||||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
/>
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
<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's issued (FR-PROC-03..05).</p>
|
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it'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"
|
||||||
@@ -25,6 +25,7 @@ import { toast } from "@/components/ui/toast"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||||
@@ -66,6 +67,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const invoiceId = Number(resolvedParams.id)
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [printOpen, setPrintOpen] = useState(false)
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -302,17 +304,23 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<button
|
||||||
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => setPrintOpen(true)}
|
||||||
rel="noopener noreferrer"
|
|
||||||
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
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SalesInvoicePrintDialog
|
||||||
|
invoiceId={invoice.salesInvoiceId}
|
||||||
|
invoiceNo={invoice.invoiceNo}
|
||||||
|
open={printOpen}
|
||||||
|
onOpenChange={setPrintOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
<section className="bg-white print:bg-white">
|
<section className="bg-white print:bg-white">
|
||||||
|
|||||||
@@ -1,197 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Printer } from "lucide-react"
|
import { useParams } from "next/navigation"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { SalesInvoicePrintPreview } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoice } from "@/types/sales"
|
|
||||||
|
|
||||||
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
export default function SalesInvoicePrintPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
const invoiceId = Number(params.id)
|
const invoiceId = Number(params.id)
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Number.isFinite(invoiceId)) {
|
|
||||||
setError(`Invalid invoice id '${params.id}'.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Promise.all([
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
|
||||||
itemsApi.list({ pageSize: 200 }),
|
|
||||||
uomsApi.list({ pageSize: 200 }),
|
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
|
||||||
salesApi.getInvoice(invoiceId),
|
|
||||||
])
|
|
||||||
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
|
||||||
setCustomers(cust.items)
|
|
||||||
setItems(itemRes.items)
|
|
||||||
setUoms(uomRes.items)
|
|
||||||
setWarehouses(whRes.items)
|
|
||||||
setInvoice(doc.data)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
}, [params.id, invoiceId])
|
|
||||||
|
|
||||||
const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice])
|
|
||||||
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
|
||||||
|
|
||||||
if (error && !invoice) {
|
|
||||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
|
||||||
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 print:block print:gap-0">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between gap-3 print:hidden">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
<Link href={`/dashboard/sales/invoices/${params.id}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to invoice">
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => window.print()}>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="invoice-sheet rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
<SalesInvoicePrintPreview invoiceId={invoiceId} className="overflow-hidden rounded-2xl border shadow-sm" />
|
||||||
<div className="invoice-header mb-6 grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
|
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 text-sm md:justify-items-end">
|
|
||||||
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
|
||||||
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
|
|
||||||
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
|
||||||
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
|
||||||
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
|
||||||
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
|
||||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[34%]">Item</TableHead>
|
|
||||||
<TableHead>UOM</TableHead>
|
|
||||||
<TableHead className="text-right">Qty</TableHead>
|
|
||||||
<TableHead className="text-right">Free</TableHead>
|
|
||||||
<TableHead className="text-right">Unit price</TableHead>
|
|
||||||
<TableHead className="text-right">Discount</TableHead>
|
|
||||||
<TableHead className="text-right">Tax</TableHead>
|
|
||||||
<TableHead className="text-right">Line total</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{invoice.lines.map((line) => (
|
|
||||||
<TableRow key={line.salesInvoiceLineId}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-foreground">{line.description}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
|
||||||
<div className="mt-6 rounded-2xl border border-dashed p-4 print:break-inside-avoid">
|
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
|
|
||||||
<div key={line.salesInvoiceLineId} className="rounded-xl border bg-emerald-500/5 p-3">
|
|
||||||
<div className="font-medium text-foreground">{line.description}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Invoice qty: {line.qty.toFixed(2)}</div>
|
|
||||||
<div className="mt-1 text-sm text-foreground">Free qty: {line.freeQty.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border p-4">
|
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-3 space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>{invoice.totals.subtotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Discount total</span><span>{invoice.totals.discountTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Free qty total</span><span>{invoice.totals.freeQtyTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Tax total</span><span>{invoice.totals.taxTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between border-t pt-2 text-base font-semibold"><span>Net payable</span><span>{invoice.totals.netPayable.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between text-muted-foreground"><span>Paid</span><span>{invoice.totals.paidAmount.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between text-muted-foreground"><span>Balance</span><span>{invoice.totals.balanceAmount.toFixed(2)}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</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, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, FileText, Plus, 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"
|
||||||
@@ -15,12 +14,13 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { FilterBar } from "@/components/ui/filter-bar"
|
import { FilterBar } from "@/components/ui/filter-bar"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Field, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
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 { 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 { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||||
@@ -30,10 +30,41 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type StatusFilter = SalesInvoiceStatus | "All"
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
// Mirrors the server's "overdue" definition (DashboardService.OverdueAgeDays) — a Posted
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
// invoice with an outstanding balance older than this many days from its invoice date.
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
const OVERDUE_AGE_DAYS = 30
|
||||||
const MOBILE_PAGE_SIZE_STEP = 25
|
|
||||||
|
function formatAmount(n: number) {
|
||||||
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
type DateRange = "All" | "Today" | "7d" | "30d"
|
||||||
|
|
||||||
|
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
|
||||||
|
{ value: "All", label: "All time", days: null },
|
||||||
|
{ value: "Today", label: "Today", days: 1 },
|
||||||
|
{ value: "7d", label: "7 days", days: 7 },
|
||||||
|
{ value: "30d", label: "30 days", days: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||||
|
function toIsoDate(d: Date) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
|
||||||
|
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
|
||||||
|
const days = dateRanges.find((r) => r.value === range)?.days
|
||||||
|
if (!days) return {}
|
||||||
|
const today = new Date()
|
||||||
|
const from = new Date(today)
|
||||||
|
from.setDate(today.getDate() - (days - 1))
|
||||||
|
return { from: toIsoDate(from), to: toIsoDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
|
||||||
|
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
|
||||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -42,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()
|
||||||
|
|
||||||
@@ -50,14 +80,19 @@ export default function SalesInvoicesPage() {
|
|||||||
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 [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>("All")
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
// rowsPerPage is the user's choice; pageSize is what's fetched (grows on mobile "Show more").
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
|
||||||
|
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
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 [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)
|
||||||
@@ -71,8 +106,8 @@ export default function SalesInvoicesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setPageSize(PAGE_SIZE)
|
setPageSize(rowsPerPage)
|
||||||
}, [query, status, activeBranchId, customerId, warehouseId])
|
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||||
@@ -89,6 +124,7 @@ export default function SalesInvoicesPage() {
|
|||||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
|
...dateRangeBounds(dateRange),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRows(res.items)
|
setRows(res.items)
|
||||||
@@ -96,11 +132,11 @@ export default function SalesInvoicesPage() {
|
|||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
.finally(() => setShowMoreLoading(false))
|
||||||
}, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId])
|
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||||
|
|
||||||
function handleShowMore() {
|
function handleShowMore() {
|
||||||
setShowMoreLoading(true)
|
setShowMoreLoading(true)
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
setPageSize((s) => s + rowsPerPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm(row: SalesInvoiceSummary) {
|
async function handleConfirm(row: SalesInvoiceSummary) {
|
||||||
@@ -131,18 +167,34 @@ 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()) &&
|
||||||
|
(!overdueOnly || (row.status === "Posted" && row.totals.balanceAmount > 0 && new Date(row.invoiceDate) < overdueSince))
|
||||||
|
) ?? []
|
||||||
)
|
)
|
||||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
}, [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)
|
||||||
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
const printRange = dateRangeBounds(dateRange)
|
||||||
|
const printHref =
|
||||||
|
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
|
||||||
|
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -168,7 +220,30 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiRow tiles={stats ? [{ label: "Overdue Invoices", value: stats.overdueSalesInvoices, icon: AlertTriangle }] : null} />
|
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
|
||||||
|
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats ? (
|
||||||
|
<StatCard
|
||||||
|
label="Overdue Invoices"
|
||||||
|
value={stats.overdueSalesInvoices}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
active={overdueOnly}
|
||||||
|
onClick={toggleOverdueOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-22.5 w-64 rounded-xl" />
|
||||||
|
)}
|
||||||
|
{rows !== null ? (
|
||||||
|
<>
|
||||||
|
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
||||||
|
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
|
||||||
|
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card shadow-sm">
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -203,19 +278,38 @@ export default function SalesInvoicesPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{tabs.map((t) => (
|
<Select<StatusFilter>
|
||||||
<button
|
value={status}
|
||||||
key={t.value}
|
onValueChange={(v) => setStatus(v ?? "All")}
|
||||||
onClick={() => setStatus(t.value)}
|
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
|
||||||
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{t.label}
|
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
||||||
</button>
|
<ListFilter className="size-4 text-muted-foreground" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select<DateRange>
|
||||||
|
value={dateRange}
|
||||||
|
onValueChange={(v) => setDateRange(v ?? "All")}
|
||||||
|
items={dateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Date range">
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dateRanges.map((r) => (
|
||||||
|
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -240,21 +334,6 @@ export default function SalesInvoicesPage() {
|
|||||||
<>
|
<>
|
||||||
{/* Mobile: stacked cards. */}
|
{/* Mobile: stacked cards. */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Gross</div>
|
|
||||||
<div className="font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Discount</div>
|
|
||||||
<div className="font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Net</div>
|
|
||||||
<div className="font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visibleRows.map((row) => (
|
{visibleRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.salesInvoiceId}
|
key={row.salesInvoiceId}
|
||||||
@@ -262,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">
|
||||||
@@ -303,8 +388,11 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
|
|
||||||
<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)}>
|
||||||
|
<ReceiptText className="size-4" />
|
||||||
|
Invoice
|
||||||
|
</Button>
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -317,7 +405,7 @@ export default function SalesInvoicesPage() {
|
|||||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.totals.balanceAmount > 0 && (
|
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -331,7 +419,6 @@ export default function SalesInvoicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -366,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>
|
||||||
@@ -391,6 +478,16 @@ export default function SalesInvoicesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPrintInvoice(row)}
|
||||||
|
aria-label={`View invoice ${row.invoiceNo}`}
|
||||||
|
title="Invoice"
|
||||||
|
>
|
||||||
|
<ReceiptText className="size-4" />
|
||||||
|
</Button>
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -424,26 +521,14 @@ export default function SalesInvoicesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden border-t px-4 py-3 md:block">
|
|
||||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Gross total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Discount total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Net total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div className="hidden border-t px-4 py-4 md:block">
|
<div className="hidden border-t px-4 py-4 md:block">
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
<PaginationFooter
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={setPage}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={setRowsPerPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -451,6 +536,12 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
|
<SalesInvoicePrintDialog
|
||||||
|
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
||||||
|
invoiceNo={printInvoice?.invoiceNo}
|
||||||
|
open={printInvoice !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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 "Require branches" 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
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -253,5 +253,20 @@
|
|||||||
.invoice-sheet th {
|
.invoice-sheet th {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SalesInvoicePrintPreview portals a print copy of the sheet into <body>; while it's there,
|
||||||
|
print only that copy — not the app, the on-screen preview, or an open dialog. */
|
||||||
|
body:has(> .invoice-print-root) > :not(.invoice-print-root) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-print-root {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The print copy never shows on screen. */
|
||||||
|
.invoice-print-root {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
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("")
|
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've sent a verification code to your email address. Enter it below to continue.
|
We'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'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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const status = searchParams.get("status") ?? "All"
|
const status = searchParams.get("status") ?? "All"
|
||||||
const query = searchParams.get("q") ?? ""
|
const query = searchParams.get("q") ?? ""
|
||||||
|
const from = searchParams.get("from") ?? undefined
|
||||||
|
const to = searchParams.get("to") ?? undefined
|
||||||
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||||
@@ -40,7 +42,7 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
salesApi.listInvoices({ page: 1, pageSize: 200 }),
|
salesApi.listInvoices({ page: 1, pageSize: 200, from, to }),
|
||||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
])
|
])
|
||||||
@@ -50,7 +52,7 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
setTemplate(templateRes.data)
|
setTemplate(templateRes.data)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [from, to])
|
||||||
|
|
||||||
const visibleRows = rows?.filter((row) => {
|
const visibleRows = rows?.filter((row) => {
|
||||||
const matchesStatus = status === "All" || row.status === status
|
const matchesStatus = status === "All" || row.status === status
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||||
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import {
|
||||||
|
A4_SHEET_CLASS,
|
||||||
|
ACCENT,
|
||||||
|
DocumentPrintDialog,
|
||||||
|
DocumentPrintPreview,
|
||||||
|
HeaderRule,
|
||||||
|
PaperSize,
|
||||||
|
ReceiptRow,
|
||||||
|
ReceiptRule,
|
||||||
|
SectionTitle,
|
||||||
|
formatDate,
|
||||||
|
money,
|
||||||
|
printColorExact,
|
||||||
|
qty,
|
||||||
|
receiptSheetClass,
|
||||||
|
} from "@/components/print/DocumentPrint"
|
||||||
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||||
|
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||||
|
|
||||||
|
/** Everything both layouts render, resolved once. */
|
||||||
|
interface InvoiceDoc {
|
||||||
|
invoice: SalesInvoice
|
||||||
|
customer: Customer | null
|
||||||
|
payments: SalesInvoicePayment[]
|
||||||
|
company: CompanyProfile | null
|
||||||
|
template: InvoiceTemplateConfig | null
|
||||||
|
dueDate: Date
|
||||||
|
notes: string[]
|
||||||
|
showLogo: boolean
|
||||||
|
showBank: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||||
|
{/* Title + meta */}
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-5xl font-extrabold tracking-wide text-neutral-900">INVOICE</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">Invoice No.</dt>
|
||||||
|
<dd>{invoice.invoiceNo}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
|
||||||
|
<dd>{formatDate(invoice.invoiceDate)}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Due Date</dt>
|
||||||
|
<dd>{formatDate(dueDate)}</dd>
|
||||||
|
{invoice.status !== "Posted" && (
|
||||||
|
<>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||||
|
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.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 />
|
||||||
|
|
||||||
|
{/* From / Bill To */}
|
||||||
|
<div className="grid grid-cols-2 gap-10">
|
||||||
|
<div>
|
||||||
|
<SectionTitle>From</SectionTitle>
|
||||||
|
{company ? (
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
|
||||||
|
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
|
||||||
|
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
{company.phone && <p>{company.phone}</p>}
|
||||||
|
{company.email && <p>{company.email}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-neutral-500 print:hidden">Company details aren't set up yet (Settings → Company Profile).</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Bill To</SectionTitle>
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{invoice.customerSnapshotName}</p>
|
||||||
|
{customer && (customer.addressLine1 || customer.city) && (
|
||||||
|
<>
|
||||||
|
<p>{[customer.addressLine1, customer.addressLine2].filter(Boolean).join(", ")}</p>
|
||||||
|
<p>{[customer.city, customer.country].filter(Boolean).join(", ")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
{customer?.phone && <p>{customer.phone}</p>}
|
||||||
|
{customer?.email && <p>{customer.email}</p>}
|
||||||
|
{invoice.customerSnapshotTaxNo && <p>Tax No: {invoice.customerSnapshotTaxNo}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<SectionTitle>Item Details</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 Price</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Discount</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Tax</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<tr key={line.salesInvoiceLineId} className="border-b border-neutral-300">
|
||||||
|
<td className="px-2 py-2.5">{line.description}{line.isFreeIssue && <span className="ml-1 text-xs text-neutral-500">(free issue)</span>}</td>
|
||||||
|
<td className="px-2 py-2.5 text-center tabular-nums">
|
||||||
|
{qty(line.qty)}
|
||||||
|
{line.freeQty > 0 && <span className="block text-xs text-neutral-500">+ {qty(line.freeQty)} free</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitPrice)}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountAmount > 0 ? `-${money(line.discountAmount)}` : "—"}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{line.taxAmount > 0 ? money(line.taxAmount) : "—"}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes / totals */}
|
||||||
|
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
|
||||||
|
<div>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Notes / Terms</SectionTitle>
|
||||||
|
<ul className="text-xs">
|
||||||
|
{notes.map((note, i) => (
|
||||||
|
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
|
||||||
|
{t.discountTotal > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(t.discountTotal)}</span></div>}
|
||||||
|
{t.taxTotal > 0 && <div className="flex justify-between"><span>Tax</span><span className="tabular-nums">{money(t.taxTotal)}</span></div>}
|
||||||
|
{t.roundOff !== 0 && <div className="flex justify-between"><span>Round off</span><span className="tabular-nums">{money(t.roundOff)}</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(t.netPayable)}</span>
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
|
||||||
|
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
|
||||||
|
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method */}
|
||||||
|
<div className="mt-8 print:break-inside-avoid">
|
||||||
|
<SectionTitle>Payment Method</SectionTitle>
|
||||||
|
{payments.length > 0 ? (
|
||||||
|
<ul className="text-xs">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<li key={p.salesInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
|
||||||
|
<span>
|
||||||
|
{formatDate(p.paymentDate)} · {p.bankAccountName}
|
||||||
|
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{money(p.amount)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBank && (
|
||||||
|
<div className="mt-3 text-xs leading-relaxed">
|
||||||
|
<p className="font-bold text-neutral-900">Bank transfer</p>
|
||||||
|
{company!.bankName && <p>Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</p>}
|
||||||
|
{company!.accountName && <p>Account name: {company!.accountName}</p>}
|
||||||
|
{company!.accountNumber && <p>Account number: {company!.accountNumber}</p>}
|
||||||
|
{company!.swiftCode && <p>SWIFT: {company!.swiftCode}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thermal receipt layout (80mm or 58mm roll): one centred column, no fills (thermal heads print black only). */
|
||||||
|
function ReceiptSheet({
|
||||||
|
invoice,
|
||||||
|
customer,
|
||||||
|
payments,
|
||||||
|
company,
|
||||||
|
template,
|
||||||
|
dueDate,
|
||||||
|
notes,
|
||||||
|
showLogo,
|
||||||
|
narrow,
|
||||||
|
}: InvoiceDoc & { narrow?: boolean }) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
<div className={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>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
|
||||||
|
{company.phone && <p>Tel: {company.phone}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-base font-extrabold tracking-[0.2em]">INVOICE</p>
|
||||||
|
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Invoice No." value={invoice.invoiceNo} />
|
||||||
|
<ReceiptRow label="Date" value={formatDate(invoice.invoiceDate)} />
|
||||||
|
<ReceiptRow label="Due" value={formatDate(dueDate)} />
|
||||||
|
<ReceiptRow label="Customer" value={invoice.customerSnapshotName} />
|
||||||
|
{customer?.phone && <ReceiptRow label="Phone" value={customer.phone} />}
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="flex justify-between font-bold">
|
||||||
|
<span>Item</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
</div>
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<div key={line.salesInvoiceLineId}>
|
||||||
|
<p className="font-medium">{line.description}{line.isFreeIssue ? " (free)" : ""}</p>
|
||||||
|
<div className="flex justify-between gap-2 tabular-nums">
|
||||||
|
<span>
|
||||||
|
{qty(line.qty)} x {money(line.unitPrice)}
|
||||||
|
{line.freeQty > 0 && ` + ${qty(line.freeQty)} free`}
|
||||||
|
</span>
|
||||||
|
<span>{money(line.lineTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{line.discountAmount > 0 && <p className="text-right tabular-nums">Disc -{money(line.discountAmount)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
|
||||||
|
{t.discountTotal > 0 && <ReceiptRow label="Discount" value={`-${money(t.discountTotal)}`} />}
|
||||||
|
{t.taxTotal > 0 && <ReceiptRow label="Tax" value={money(t.taxTotal)} />}
|
||||||
|
{t.roundOff !== 0 && <ReceiptRow label="Round off" value={money(t.roundOff)} />}
|
||||||
|
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||||
|
<ReceiptRow label="TOTAL" value={money(t.netPayable)} bold />
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
|
||||||
|
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
<p className="font-bold">Payment</p>
|
||||||
|
{payments.map((p) => (
|
||||||
|
<ReceiptRow key={p.salesInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<p className="text-center font-bold">Thank you!</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||||
|
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||||
|
function useInvoiceDoc(invoiceId: number) {
|
||||||
|
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) return
|
||||||
|
let cancelled = false
|
||||||
|
salesApi
|
||||||
|
.getInvoice(invoiceId)
|
||||||
|
.then(async ({ data: invoice }) => {
|
||||||
|
const [cust, payments, companyRes, templateRes] = await Promise.all([
|
||||||
|
customersApi.get(invoice.customerId).catch(() => null),
|
||||||
|
salesApi.listInvoicePayments(invoiceId).catch(() => []),
|
||||||
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const customer = cust?.data ?? null
|
||||||
|
const company = companyRes.data
|
||||||
|
const template = templateRes.data
|
||||||
|
|
||||||
|
// Invoices carry no due date of their own — it's the customer's credit terms from the invoice date.
|
||||||
|
const dueDate = new Date(invoice.invoiceDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + (customer?.creditDays ?? 0))
|
||||||
|
|
||||||
|
setDoc({
|
||||||
|
invoice,
|
||||||
|
customer,
|
||||||
|
payments,
|
||||||
|
company,
|
||||||
|
template,
|
||||||
|
dueDate,
|
||||||
|
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||||
|
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setLoadError(errorMessage(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [invoiceId])
|
||||||
|
|
||||||
|
return { doc, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||||
|
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareText({ invoice, company }: InvoiceDoc) {
|
||||||
|
const from = company ? ` from ${company.tradeName || company.legalName}` : ""
|
||||||
|
return `Invoice ${invoice.invoiceNo}${from} — total ${money(invoice.totals.netPayable)}` +
|
||||||
|
(invoice.totals.balanceAmount > 0 ? `, balance due ${money(invoice.totals.balanceAmount)}.` : ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoice preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
|
||||||
|
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||||
|
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||||
|
return (
|
||||||
|
<DocumentPrintPreview
|
||||||
|
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||||
|
error={error}
|
||||||
|
fileName={doc?.invoice.invoiceNo ?? "invoice"}
|
||||||
|
share={doc ? { title: `Invoice ${doc.invoice.invoiceNo}`, text: shareText(doc), phone: doc.customer?.phone } : null}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup form of the invoice preview. */
|
||||||
|
export function SalesInvoicePrintDialog({
|
||||||
|
invoiceId,
|
||||||
|
invoiceNo,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
invoiceId: number | null
|
||||||
|
invoiceNo?: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DocumentPrintDialog title={`Invoice${invoiceNo ? ` ${invoiceNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
|
||||||
|
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||||
|
</DocumentPrintDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
|
|||||||
const warning = "bg-warning/10 text-warning border-transparent"
|
const 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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div
|
||||||
<Icon className="size-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-2">
|
|
||||||
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
|
||||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasDelta && (
|
|
||||||
<div className="flex items-center gap-1 text-xs">
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold",
|
"flex shrink-0 items-center justify-center rounded-full bg-primary/10",
|
||||||
isGood ? "text-success" : "text-destructive"
|
size === "sm" ? "size-9" : "size-9"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Icon className={size === "sm" ? "size-4 text-primary" : "size-4 text-primary"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<p className={cn("truncate font-medium text-muted-foreground", size === "sm" ? "text-sm" : "text-sm")}>{label}</p>
|
||||||
|
<p className={cn("font-bold tracking-tight text-foreground", size === "sm" ? "text-xl" : "text-xl")}>
|
||||||
|
{formatValue(value)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasDelta ? (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
|
||||||
{isPositive ? "+" : "-"}
|
{isPositive ? "+" : "-"}
|
||||||
{Math.abs(delta).toFixed(1)}%
|
{Math.abs(delta).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
caption && <p className="text-xs text-muted-foreground">{caption}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {} })
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type SalesReportId = string
|
|||||||
|
|
||||||
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||||
status?: SalesInvoiceStatus
|
status?: SalesInvoiceStatus
|
||||||
|
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Generated
+259
-23
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ export default function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*"],
|
matcher: ["/dashboard/:path*", "/change-password"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()`). */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user