Merge pull request 'Dev' (#114) from Dev into production

Reviewed-on: #114
This commit was merged in pull request #114.
This commit is contained in:
2026-10-17 18:25:55 +05:30
74 changed files with 2735 additions and 1194 deletions
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
[HttpPut("{**path}")] [HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct); public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
[HttpPatch("{**path}")]
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
[HttpDelete("{**path}")]
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct) private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{ {
var body = method == HttpMethod.Get ? null : Request.Body; var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct); var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult return new ContentResult
{ {
@@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
[FromQuery] string? q,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
+15 -1
View File
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct)); => Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary> /// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
[HttpPut("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateAsync(userId, request, ct));
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")] [HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct) public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct)); => Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
[HttpPost("{userId:int}/send-reset-link")]
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
=> Ok(await _users.SendResetLinkAsync(userId, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions" /// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary> /// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")] [HttpGet("{userId:int}/login-devices")]
@@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser
public string? Email { get; set; } public string? Email { get; set; }
} }
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
public sealed class AdminUpdateUserRequest
{
public string? FullName { get; set; }
public string? UserName { get; set; }
public string? Email { get; set; }
public string? MobileNumber { get; set; }
}
public sealed class RegisterRequest public sealed class RegisterRequest
{ {
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary> /// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
@@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto(
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt, DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines); SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
/// <summary>Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page.</summary>
public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount);
public sealed record SalesInvoiceSummaryDto( public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
+15
View File
@@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
} }
/// <summary>
/// Edits an existing user's identity fields in both backends: the local shadow row
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
/// account itself via the admin-only AdminUpdateUser function. A user created before
/// AuthHex linking (AuthUserId null) only updates locally.
/// </summary>
public sealed class UpdateManagedUserRequest
{
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? MobileNumber { get; set; }
}
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary> /// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description); public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
/// <summary>Bin/location resource (docs/11 §2.5).</summary> /// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits.</summary>
public static class PhoneRules
{
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$";
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567).";
}
public sealed class CreateWarehouseRequest public sealed class CreateWarehouseRequest
{ {
[Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; } [StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; } [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; } [Required] public int BranchId { get; set; }
} }
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
[Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; } [StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; } [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; } [Required] public int BranchId { get; set; }
} }
@@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct); => CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct) public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{ => CallAsync<DeleteAuthHexUserResult>(
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser", "user", "deleteUser",
new { email, mobileNumber, nic, userName = username }, new { email, mobileNumber, nic, userName = username },
null, ct, null, ct,
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey }); extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminUpdateUser",
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
null, ct,
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminSetUserStatus",
new { userId, isActive },
null, ct,
extraHeaders: AdminHeaders());
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
private Dictionary<string, string> AdminHeaders()
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
} }
// ---- RecoveryManager ------------------------------------------------ // ---- RecoveryManager ------------------------------------------------
@@ -38,6 +38,15 @@ public interface IAuthHexClient
/// </summary> /// </summary>
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct); Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
/// <summary>Admin-only: updates another user's identity fields (not self-service —
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
/// Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
// RecoveryManager (POST /api/recovery) // RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
@@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found."); ?? throw new NotFoundException($"Branch {branchId} was not found.");
if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deactivated.",
409);
branch.Status = status; branch.Status = status;
branch.UpdatedAt = DateTime.UtcNow; branch.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found."); ?? throw new NotFoundException($"Branch {branchId} was not found.");
if (branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deleted.",
409);
_branches.Remove(branch); _branches.Remove(branch);
try try
{ {
@@ -8,6 +8,8 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
/// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary>
Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
Task<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);
@@ -17,16 +17,27 @@ public interface IUserManagementService
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default); Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>
/// Edits DisplayName/Email (and MobileNumber upstream) on both the local shadow row and,
/// when linked, the AuthHex identity itself (via the admin-only AdminUpdateUser function).
/// </summary>
Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default);
/// <summary> /// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what /// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no /// BranchAccessService and the rest of ERPCore gate on — and, when the user has a
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus /// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the /// function (deactivating additionally invalidates their live AuthHex sessions), so a
/// credential itself stays live upstream; this only revokes the account's /// user shown Inactive here can no longer log in at all, not just inside ERPCore.
/// standing inside ERPCore.
/// </summary> /// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>
/// Emails the user a time-limited (15 min) password-reset link via AuthHex's existing
/// self-service forgot-password flow, triggered on their behalf by an admin.
/// </summary>
Task<SendResetLinkResponse> SendResetLinkAsync(int userId, 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);
@@ -25,6 +25,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
public const string ApprovalNavCode = "products.price-adjustments"; public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments; private readonly IRepository<PriceAdjustment> _adjustments;
private readonly IRepository<PriceAdjustmentLine> _lines;
private readonly IRepository<Item> _items; private readonly IRepository<Item> _items;
private readonly IRepository<ItemPriceHistory> _history; private readonly IRepository<ItemPriceHistory> _history;
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
@@ -36,11 +37,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public PriceAdjustmentService( public PriceAdjustmentService(
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history, IRepository<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items,
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals, IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> config,
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{ {
_adjustments = adjustments; _adjustments = adjustments;
_lines = lines;
_items = items; _items = items;
_history = history; _history = history;
_users = users; _users = users;
@@ -92,10 +95,14 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
var ids = request.Lines.Select(l => l.ItemId).ToList(); var ids = request.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
var pendingItemIds = await _adjustments.Query().AsNoTracking() // Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval) // plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo })) // Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this
.Where(x => ids.Contains(x.ItemId)) // predates — 2026-10-16 MySQL-port regression).
var pendingItemIds = await _lines.Query().AsNoTracking()
.Where(l => ids.Contains(l.ItemId)
&& (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval))
.Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo })
.ToListAsync(ct); .ToListAsync(ct);
var lines = new List<PriceAdjustmentLine>(); var lines = new List<PriceAdjustmentLine>();
+24 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions; private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,14 @@ 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;
@@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct) var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found."); ?? throw new NotFoundException($"Role {roleId} was not found.");
if (status == EntityStatus.Inactive)
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
role.Status = status; role.Status = status;
role.UpdatedAt = DateTime.UtcNow; role.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct) var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found."); ?? throw new NotFoundException($"Role {roleId} was not found.");
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
try try
{ {
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
@@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
/// <summary>
/// A role still assigned to at least one local (ERPCore shadow) user cannot be
/// deactivated or deleted — that user would be left pointing at a role they can no
/// longer effectively hold. Reassign them to a different role first.
/// </summary>
private async Task EnsureNotAssignedToAnyUserAsync(int roleId, string action, CancellationToken ct)
{
var assignedUserCount = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId == roleId, ct);
if (assignedUserCount > 0)
throw new DomainException(
ErrorCodes.RoleInUse,
$"This role is assigned to {assignedUserCount} user(s) and cannot be {action}. Reassign them to a different role first.",
409);
}
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default) public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
{ {
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
+27 -3
View File
@@ -41,13 +41,37 @@ public sealed class SalesConfigService : ISalesConfigService
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default) public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{ {
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse) var config = await LoadAsync(ct);
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded."); // "Allow cash customer" can be on with no customer linked — the flag was set outside
// UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer
// because there's no id to offer, so link (or create) CUST-CASH here instead.
if (config.AllowCashCustomer && config.CashCustomerId is null)
{
var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct);
if (tracked is not null && tracked.CashCustomerId is null)
{
tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// A concurrent save got there first — whatever it stored is re-read below.
}
}
config = await LoadAsync(ct);
}
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion); return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
} }
private async Task<SalesConfig> LoadAsync(CancellationToken ct) =>
await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
public async Task<ETagged<SalesConfigDto>> UpdateAsync( public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
@@ -53,10 +53,44 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
{ {
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines); var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
if (!string.IsNullOrWhiteSpace(query.Q))
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
{ {
var term = query.Q.Trim(); var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
// Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule.
var today = DateTime.UtcNow.Date;
var totals = await filtered
.GroupBy(_ => 1)
.Select(g => new
{
Count = g.Count(),
Net = g.Sum(x => x.GrandTotal),
Paid = g.Sum(x => x.PaidAmount),
Balance = g.Sum(x => x.BalanceAmount > 0 ? x.BalanceAmount : 0),
Unpaid = g.Count(x => x.BalanceAmount > 0),
Overdue = g.Count(x => x.BalanceAmount > 0 && x.InvoiceDate < today),
})
.FirstOrDefaultAsync(ct);
return totals is null
? new SalesInvoiceListTotalsDto(0, 0m, 0m, 0m, 0, 0)
: new SalesInvoiceListTotalsDto(totals.Count, totals.Net, totals.Paid, totals.Balance, totals.Unpaid, totals.Overdue);
}
/// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary>
private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid)
{
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%")); q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
} }
if (status is not null) q = q.Where(x => x.Status == status); if (status is not null) q = q.Where(x => x.Status == status);
@@ -72,11 +106,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// paid=true: nothing left to pay; paid=false: a balance is still due. // paid=true: nothing left to pay; paid=false: a balance is still due.
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
if (paid == false) q = q.Where(x => x.BalanceAmount > 0); if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
return q;
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
} }
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default) public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService
return Map(user); return Map(user);
} }
public async Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
var displayName = request.DisplayName.Trim();
var email = request.Email.Trim();
// Source of truth first — if AuthHex rejects this (e.g. email already taken by
// another identity), the local row must not drift out of sync with it.
if (user.AuthUserId is Guid authUserId)
{
await _authHex.AdminUpdateUserAsync(authUserId, new AdminUpdateUserRequest
{
FullName = displayName,
Email = email,
MobileNumber = request.MobileNumber
}, ct);
}
user.DisplayName = displayName;
user.Email = email;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default) public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{ {
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct) var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found."); ?? throw new NotFoundException($"User {userId} was not found.");
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
// could still log in directly against AuthHex.
if (user.AuthUserId is Guid authUserId)
await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct);
user.Status = status; user.Status = status;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return Map(user); return Map(user);
} }
public async Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
if (string.IsNullOrWhiteSpace(user.Email))
throw new DomainException(ErrorCodes.Validation, "This user has no email address on file.", 422);
var result = await _authHex.ForgotPasswordAsync(new ForgotPasswordRequest
{
Identifier = user.Email,
UseResetLink = true
}, ct);
return new SendResetLinkResponse(result.ExpiresAt);
}
public async Task<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);
@@ -56,6 +56,7 @@ public static class ErrorCodes
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY"; public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4) // Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE"; public const string TemplateInUse = "TEMPLATE_IN_USE";
+2 -1
View File
@@ -9,7 +9,8 @@
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com" "DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5011",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
}, },
"GeneralLedgerService": { "GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/" "BaseUrl": "https://localhost:7024/api/v1/"
+2 -1
View File
@@ -3,7 +3,8 @@
"SigningKey": "" "SigningKey": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:6703" "BaseUrl": "http://localhost:6703",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
}, },
"GeneralLedgerService": { "GeneralLedgerService": {
"BaseUrl": "http://localhost:6704/api/v1/", "BaseUrl": "http://localhost:6704/api/v1/",
@@ -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, CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } 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"
@@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both" type AccountTypeFilter = CashBankAccountType | "Both"
@@ -27,23 +31,25 @@ export default function BankAccountsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both") const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table const [editTarget, setEditTarget] = useState<CashAndBankAccountDto | null>(null)
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below. const [editName, setEditName] = useState("")
useEffect(() => { const [editError, setEditError] = useState<string | null>(null)
let cancelled = false const [editSubmitting, setEditSubmitting] = useState(false)
const [statusPendingId, setStatusPendingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
function load() {
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
.then(([banks, gl]) => { .then(([banks, gl]) => {
if (cancelled) return
setAccounts(banks) setAccounts(banks)
setGlAccounts(gl.items) setGlAccounts(gl.items)
}) })
.catch((err) => { .catch((err) => setError(errorMessage(err)))
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
} }
}, [accountType])
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(load, [accountType])
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts]) const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
@@ -64,6 +70,57 @@ export default function BankAccountsPage() {
}) })
}, [accounts, search, glAccountsById]) }, [accounts, search, glAccountsById])
function openEdit(a: CashAndBankAccountDto) {
setEditTarget(a)
setEditName(a.accountName)
setEditError(null)
}
async function handleRename() {
if (!editTarget) return
if (!editName.trim()) {
setEditError("Account name is required")
return
}
setEditSubmitting(true)
try {
await bankAccountsApi.updateCash(editTarget.accountId, editName.trim())
toast.success("Cash account renamed")
setEditTarget(null)
load()
} catch (err) {
setEditError(errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
async function toggleStatus(a: CashAndBankAccountDto) {
setStatusPendingId(a.accountId)
try {
await bankAccountsApi.setCashStatus(a.accountId, !a.isActive)
toast.success(a.isActive ? "Cash account deactivated" : "Cash account activated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setStatusPendingId(null)
}
}
async function handleDelete(a: CashAndBankAccountDto) {
setDeletingId(a.accountId)
try {
await bankAccountsApi.deleteCash(a.accountId)
toast.success("Cash account deleted")
load()
} catch (err) {
toast.error("Could not delete cash account", errorMessage(err))
} finally {
setDeletingId(null)
}
}
return ( 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 items-center justify-between">
@@ -167,6 +224,48 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
{a.accountType === CashBankAccountType.Cash ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label={`Edit ${a.accountName}`} title={`Edit ${a.accountName}`}>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(a.isActive ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
aria-label={a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
title={a.isDefault && a.isActive ? "The default cash account can't be deactivated" : a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
disabled={statusPendingId === a.accountId || (a.isDefault === true && a.isActive === true)}
onClick={() => toggleStatus(a)}
>
{a.isActive ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${a.accountName}`}
title={a.isDefault ? "The default cash account can't be deleted" : `Delete ${a.accountName}`}
disabled={deletingId === a.accountId || a.isDefault === true}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${a.accountName}?`}
description="This permanently removes the cash account. Cash accounts referenced by posted journal entries cannot be deleted."
confirmLabel="Delete"
onConfirm={() => handleDelete(a)}
/>
</AlertDialog>
</div>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
@@ -176,10 +275,10 @@ export default function BankAccountsPage() {
<Pencil className="size-4" /> <Pencil className="size-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for Editing isn&apos;t available yet — the General Ledger service has no update endpoint for bank accounts.
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) )
@@ -187,6 +286,34 @@ export default function BankAccountsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
<Dialog open={!!editTarget} onOpenChange={(v) => { if (!v) setEditTarget(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Rename cash account</DialogTitle>
<DialogDescription>
{editTarget?.isDefault
? "This is the default cash account — only its name can be changed."
: "Update this cash account's display name."}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editError}>
<FieldLabel htmlFor="cash-account-name">Account name</FieldLabel>
<Input id="cash-account-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editError} />
<FieldError errors={[editError ? { message: editError } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditTarget(null)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleRename} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch"
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"
/** What the edit dialog is editing — a category (no GL code) or a title. */
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
export default function ExpenseSetupPage() { export default function ExpenseSetupPage() {
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null) const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false) const [addTitleOpen, setAddTitleOpen] = useState(false)
const [editing, setEditing] = useState<Editing>(null) const [editing, setEditing] = useState<ExpenseTitle | null>(null)
function load() { function load() {
expenseSetupApi expenseSetupApi
@@ -45,10 +42,10 @@ export default function ExpenseSetupPage() {
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4 sm:gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Expense Setup</h1>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed. The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
</p> </p>
</div> </div>
@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() {
{categories && ( {categories && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
<nav className="flex gap-2 overflow-x-auto lg:flex-col" aria-label="Expense categories"> {/* Phones: the (fixed, four) categories as a 2×2 grid — no sideways scrolling to find one.
Tablets: one scrollable row. Desktop: a side list. */}
<nav className="grid grid-cols-2 gap-2 sm:flex sm:overflow-x-auto lg:flex-col" aria-label="Expense categories">
{categories.map((c) => ( {categories.map((c) => (
<button <button
key={c.expenseCategoryId} key={c.expenseCategoryId}
onClick={() => setSelectedId(c.expenseCategoryId)} onClick={() => setSelectedId(c.expenseCategoryId)}
aria-current={c.expenseCategoryId === selectedId ? "true" : undefined}
className={cn( className={cn(
"flex shrink-0 items-center justify-between gap-2 rounded-xl border px-4 py-3 text-left text-sm transition-colors", "flex min-h-12 min-w-0 shrink-0 items-center justify-between gap-2 rounded-xl border px-3 py-3 text-left text-sm transition-colors sm:px-4",
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground", c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
c.status === "Inactive" && "opacity-60" c.status === "Inactive" && "opacity-60"
)} )}
> >
<span className="font-medium">{c.name}</span> <span className="min-w-0 truncate font-medium">{c.name}</span>
<span className="text-xs tabular-nums">{c.titles.length}</span> <span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums">{c.titles.length}</span>
</button> </button>
))} ))}
</nav> </nav>
@@ -85,17 +85,11 @@ export default function ExpenseSetupPage() {
{selected.isSystem && <SystemBadge />} {selected.isSystem && <SystemBadge />}
{selected.status === "Inactive" && <InactiveBadge />} {selected.status === "Inactive" && <InactiveBadge />}
</div> </div>
<div className="flex gap-2"> <Button size="sm" className="h-10 w-full sm:h-8 sm:w-auto" onClick={() => setAddTitleOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setEditing({ kind: "category", row: selected })}>
<Pencil className="size-4" />
Edit category
</Button>
<Button size="sm" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
Add title Add title
</Button> </Button>
</div> </div>
</div>
{selected.code === "SALARIES" && ( {selected.code === "SALARIES" && (
<p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning"> <p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
@@ -106,7 +100,36 @@ export default function ExpenseSetupPage() {
{selected.titles.length === 0 ? ( {selected.titles.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p> <p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
) : ( ) : (
<div className="overflow-x-auto"> <>
{/* Phones: one card per title — tap anywhere on it to edit. */}
<ul className="flex flex-col gap-2 p-3 md:hidden">
{selected.titles.map((t) => (
<li key={t.expenseTitleId}>
<button
type="button"
onClick={() => setEditing(t)}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-3 text-left hover:bg-muted/40",
t.status === "Inactive" && "opacity-60"
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 font-medium">
<span className="truncate">{t.name}</span>
{t.isSystem && <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Built-in" />}
</span>
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono tabular-nums">GL {t.glExpenseAccountCode}</span>
{t.status === "Inactive" && <InactiveBadge />}
</span>
</span>
<Pencil className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="sr-only">Edit {t.name}</span>
</button>
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -128,7 +151,7 @@ export default function ExpenseSetupPage() {
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell> <TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell> <TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
<TableCell className="px-4 py-3 text-right"> <TableCell className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setEditing({ kind: "title", row: t })}> <Button variant="ghost" size="sm" onClick={() => setEditing(t)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</Button> </Button>
@@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)} )}
</div> </div>
)} )}
@@ -179,7 +203,7 @@ function InactiveBadge() {
) )
} }
function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) { function EditDialog({ editing, onClose, onSaved }: { editing: ExpenseTitle | null; onClose: () => void; onSaved: () => void }) {
const [name, setName] = useState("") const [name, setName] = useState("")
const [glCode, setGlCode] = useState("") const [glCode, setGlCode] = useState("")
const [active, setActive] = useState(true) const [active, setActive] = useState(true)
@@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs. // Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null const key = editing?.expenseTitleId ?? null
const [resetFor, setResetFor] = useState<string | null>(null) const [resetFor, setResetFor] = useState<number | null>(null)
if (key !== resetFor) { if (key !== resetFor) {
setResetFor(key) setResetFor(key)
if (editing) { if (editing) {
setName(editing.row.name) setName(editing.name)
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "") setGlCode(editing.glExpenseAccountCode)
setActive(editing.row.status === "Active") setActive(editing.status === "Active")
setError(null) setError(null)
} }
} }
if (!editing) return null if (!editing) return null
const isSystem = editing.row.isSystem const isSystem = editing.isSystem
async function submit() { async function submit() {
if (!editing) return if (!editing) return
if (!name.trim()) return setError("Enter a name") if (!name.trim()) return setError("Enter a name")
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code") if (!glCode.trim()) return setError("Enter the GL expense account code")
const status: EntityStatus = active ? "Active" : "Inactive" const status: EntityStatus = active ? "Active" : "Inactive"
setSaving(true) setSaving(true)
try { try {
if (editing.kind === "category") { await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
} else {
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
}
toast.success("Saved", name.trim()) toast.success("Saved", name.trim())
onSaved() onSaved()
onClose() onClose()
@@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle> <DialogTitle>Edit title</DialogTitle>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field> <Field>
@@ -236,13 +256,11 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} /> <Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
{isSystem && <FieldDescription>Built-in names can&apos;t be changed.</FieldDescription>} {isSystem && <FieldDescription>Built-in names can&apos;t be changed.</FieldDescription>}
</Field> </Field>
{editing.kind === "title" && (
<Field> <Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel> <FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} /> <Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription> <FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
</Field> </Field>
)}
<Field orientation="horizontal"> <Field orientation="horizontal">
<Switch id="edit-active" checked={active} onCheckedChange={setActive} /> <Switch id="edit-active" checked={active} onCheckedChange={setActive} />
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel> <FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
@@ -250,10 +268,10 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}> <Button variant="outline" className="h-11 sm:h-9" onClick={onClose} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={saving}> <Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -236,11 +236,13 @@ export default function CustomersPage() {
</Button> </Button>
} }
/> />
<DialogContent className="sm:max-w-md"> <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New customer</DialogTitle> <DialogTitle>New customer</DialogTitle>
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription> <DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Only the fields scroll — title and Create/Cancel stay visible on a phone. */}
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4 pb-1">
<FieldGroup> <FieldGroup>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name" required>Name</FieldLabel> <FieldLabel htmlFor="c-name" required>Name</FieldLabel>
@@ -263,10 +265,10 @@ export default function CustomersPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field> <Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel> <FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" /> <Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel> <FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
@@ -281,7 +283,7 @@ export default function CustomersPage() {
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel> <FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" /> <Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field> <Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel> <FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" /> <Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
@@ -291,7 +293,7 @@ export default function CustomersPage() {
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" /> <Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field> <Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel> <FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} /> <Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
@@ -318,11 +320,12 @@ export default function CustomersPage() {
</Field> </Field>
)} )}
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> </div>
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}> <div className="-mx-4 flex shrink-0 flex-col-reverse gap-3 border-t px-4 pt-3 sm:mx-0 sm:flex-row sm:justify-center sm:border-t-0 sm:px-0 sm:pt-2">
<Button variant="outline" className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}> <Button className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"} {submitting ? "Creating…" : "Create"}
</Button> </Button>
</div> </div>
@@ -0,0 +1,93 @@
"use client"
import Link from "next/link"
import { Banknote, CalendarCheck, CalendarClock, IdCard, SlidersHorizontal, Users } from "lucide-react"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
// Same entries (and permission codes) as the HRM group in the sidebar.
const sections = [
{
title: "Employees",
description: "Employee records, departments and designations.",
href: "/dashboard/hrm/employees",
code: "hrm.employees",
icon: Users,
},
{
title: "Attendance",
description: "Daily attendance and time records.",
href: "/dashboard/hrm/attendance",
code: "hrm.attendance",
icon: CalendarCheck,
},
{
title: "Leave",
description: "Leave requests, approvals and balances.",
href: "/dashboard/hrm/leave",
code: "hrm.leave",
icon: CalendarClock,
},
{
title: "Payroll",
description: "Salary runs and payslips.",
href: "/dashboard/hrm/payroll",
code: "hrm.payroll",
icon: Banknote,
},
{
title: "Settings",
description: "HRM masters and configuration.",
href: "/dashboard/hrm/settings",
code: "hrm.settings",
icon: SlidersHorizontal,
},
]
/** HRM overview — what the breadcrumb's "HRM" opens; one card per HRM section the user can see. */
export default function HrmHubPage() {
const { navCodes, isAdmin, loading } = useAuth()
// Admins see everything; otherwise the same nav codes that decide the sidebar's HRM entries.
const visible = loading ? [] : sections.filter((s) => isAdmin || navCodes.includes(s.code))
return (
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<IdCard className="size-4" />
HRM
</div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">HRM</h1>
<p className="text-sm text-muted-foreground sm:text-base">Employees, attendance, leave and payroll in one place.</p>
</div>
{!loading && visible.length === 0 && (
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
You don&apos;t have access to any HRM sections.
</p>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((section) => {
const Icon = section.icon
return (
<Link
key={section.href}
href={section.href}
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>Open</div>
</Link>
)
})}
</div>
</div>
)
}
+32 -19
View File
@@ -34,6 +34,7 @@ import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart" import LineChart from "@/components/ui/line-chart"
const TREND_DAYS = 14 const TREND_DAYS = 14
const MOBILE_TREND_DAYS = 5
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */ /** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5 const CHART_MIN_SLOTS = 5
@@ -112,6 +113,16 @@ export default function DashboardPage() {
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep // warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card. // the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile() const isMobile = useIsMobile()
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
const shownTrend = useMemo(
() => ({
labels: movementTrend.labels.slice(-trendDays),
inData: movementTrend.inData.slice(-trendDays),
outData: movementTrend.outData.slice(-trendDays),
}),
[movementTrend, trendDays]
)
const valuationRows = stats?.stockValuationByWarehouse ?? [] const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length) const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
@@ -130,26 +141,28 @@ 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"> {/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
compact stacked layout, so they would not match the stat cards on every other screen. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{loaded ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} /> <StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
</Link> </Link>
<Link href="/dashboard/stock/enquiry"> <Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} /> <StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
</Link> </Link>
<Link href="/dashboard/receiving/grn"> <Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} /> <StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
</Link> </Link>
<Link href="/dashboard/procurement/requisitions"> <Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} /> <StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
</Link> </Link>
<Link href="/dashboard/stock/counts"> <Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} /> <StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
</Link> </Link>
<Link href="/dashboard/procurement/rfqs"> <Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} /> <StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
</Link> </Link>
</> </>
) : ( ) : (
@@ -199,23 +212,23 @@ export default function DashboardPage() {
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" /> <ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base"> <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days) Stock Movement Trend (last {trendDays} days)
</h2> </h2>
</div> </div>
{loaded ? ( {loaded ? (
movementTrend.inData.some((v) => v > 0) || movementTrend.outData.some((v) => v > 0) ? ( shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
<div className="h-64 sm:h-72"> <div className="h-64 sm:h-72">
<LineChart <LineChart
labels={movementTrend.labels} labels={shownTrend.labels}
datasets={[ datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" }, { label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" }, { label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]} ]}
/> />
</div> </div>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {TREND_DAYS} days.</p> <p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
) )
) : ( ) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" /> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
@@ -230,7 +230,7 @@ function NewPurchaseReturnContent() {
<> <>
<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 sm:col-span-2"> <div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Goods Receipt Note</Label> <Label className="text-base">Goods Received Note</Label>
<Combobox<number> <Combobox<number>
items={grnOptions} items={grnOptions}
value={grnId} value={grnId}
@@ -63,6 +63,8 @@ import {
ITEM_IMAGE_MAX_COUNT, ITEM_IMAGE_MAX_COUNT,
itemImageProblem, itemImageProblem,
} from "@/components/products/ItemImagesPanel" } from "@/components/products/ItemImagesPanel"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
function skuSegment(text: string, maxLen: number): string { function skuSegment(text: string, maxLen: number): string {
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
@@ -118,6 +120,8 @@ export default function NewItemPage() {
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
/** Defaults to the first UOM once loaded; null only means none exist yet. */ /** Defaults to the first UOM once loaded; null only means none exist yet. */
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [addUomOpen, setAddUomOpen] = useState(false)
const [addBrandOpen, setAddBrandOpen] = useState(false)
// Kept as a raw string: an empty box means "no content size", which is different from 0. // Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("") const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null) const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -216,7 +220,11 @@ export default function NewItemPage() {
setBaseUomId(uoms.items[0]?.uomId ?? null) setBaseUomId(uoms.items[0]?.uomId ?? null)
setWarehouses(wh.items) setWarehouses(wh.items)
}) })
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => {
const message = errorMessage(err)
setLoadError(message)
toast.error("Could not load the form", message)
})
// Optional extra: a failure here just means only the default periods are offered. // Optional extra: a failure here just means only the default periods are offered.
warrantyPeriodsApi warrantyPeriodsApi
.list({ status: "Active" }) .list({ status: "Active" })
@@ -350,6 +358,7 @@ export default function NewItemPage() {
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths) const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
if (parsed.error !== undefined) { if (parsed.error !== undefined) {
setCustomPeriodError(parsed.error) setCustomPeriodError(parsed.error)
toast.error("Invalid warranty period", parsed.error)
return return
} }
setItemOnlyWarrantyMonths(parsed.months) setItemOnlyWarrantyMonths(parsed.months)
@@ -375,24 +384,30 @@ export default function NewItemPage() {
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own * in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
* content size — the same path as a measurable Item Variant. Name defaults to "Size". * content size — the same path as a measurable Item Variant. Name defaults to "Size".
*/ */
/** Inline message under the custom-variant box, mirrored as a toast. */
function failCustom(message: string) {
setCustomError(message)
toast.error("Could not add custom variant", message)
}
function createCustomVariant() { function createCustomVariant() {
const name = customName.trim() || (customIsSize ? "Size" : "") const name = customName.trim() || (customIsSize ? "Size" : "")
const invalid = validateItemTypeName(name).name const invalid = validateItemTypeName(name).name
if (invalid) { if (invalid) {
setCustomError(invalid) failCustom(invalid)
return return
} }
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) { if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
setCustomError(`"${name}" already exists — tick it above instead.`) failCustom(`"${name}" already exists — tick it above instead.`)
return return
} }
if (customIsSize && !measurementUnit) { if (customIsSize && !measurementUnit) {
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.") failCustom("Pick a Measurement unit at the top first — size values are entered in it.")
return return
} }
// Only one dimension may supply the content size, or a variant would have two. // Only one dimension may supply the content size, or a variant would have two.
if (customIsSize && measurableCheckedCount > 0) { if (customIsSize && measurableCheckedCount > 0) {
setCustomError("Only one size variant can be used at a time — untick the other one first.") failCustom("Only one size variant can be used at a time — untick the other one first.")
return return
} }
const local: ItemType = { const local: ItemType = {
@@ -413,6 +428,10 @@ export default function NewItemPage() {
* duplicate pair never reaches state, so nothing downstream has to defend against one. * duplicate pair never reaches state, so nothing downstream has to defend against one.
*/ */
function addValue(itemTypeId: number) { function addValue(itemTypeId: number) {
const failValue = (message: string) => {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: message }))
toast.error("Could not add value", message)
}
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) { if (!measurable) {
@@ -435,11 +454,11 @@ export default function NewItemPage() {
const qty = Number(raw) const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) { if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" })) failValue("Enter a size greater than 0")
return return
} }
if (!unit) { if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" })) failValue("Select a unit")
return return
} }
@@ -448,7 +467,7 @@ export default function NewItemPage() {
const key = measureKey(qty, unit) const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? [] const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) { if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" })) failValue("That size is already in the list")
return return
} }
@@ -708,13 +727,44 @@ export default function NewItemPage() {
const loading = !categories || !brands || !itemTypes || !config const loading = !categories || !brands || !itemTypes || !config
/** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */
function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) {
const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}`
const invalid = kind === "flat" && !!priceErrors[variantKey]
return ( return (
<div className="flex flex-col gap-6"> <Input
<div className="flex items-center gap-3"> type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[stateKey] ?? ""}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [stateKey]: value }))
if (kind === "flat")
setPriceErrors((prev) => {
if (!prev[variantKey]) return prev
const next = { ...prev }
delete next[variantKey]
return next
})
}}
placeholder="0.00"
aria-label={`${kind === "flat" ? "Flat" : kind === "min" ? "Min" : "Max"} price`}
aria-invalid={invalid}
className={cn("h-10 text-base", className)}
/>
)
}
/** Mobile-first section shell: tighter padding on phones, roomier from `sm` up. */
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
return (
<div className="flex flex-col gap-5 sm:gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p> <p className="text-sm text-muted-foreground sm:text-base">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
</div> </div>
{loadError && ( {loadError && (
@@ -726,17 +776,19 @@ 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/categories?tab=uom" className="font-semibold underline"> <button type="button" onClick={() => setAddUomOpen(true)} className="font-semibold underline">
create one first add one now
</Link> </button>
. .
</div> </div>
)} )}
{!loading && ( {!loading && (
<> <>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Basic details</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base" required>Product name</Label> <Label className="text-base" required>Product name</Label>
<Input <Input
value={productName} value={productName}
@@ -802,12 +854,13 @@ export default function NewItemPage() {
{config?.brandsEnabled && ( {config?.brandsEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label> <Label className="text-base">Brand (optional)</Label>
<div className="flex gap-2">
<Select<number | null> <Select<number | null>
value={brandId} value={brandId}
onValueChange={setBrandId} onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
> >
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select brand" /> <SelectValue placeholder="Select brand" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -818,82 +871,34 @@ export default function NewItemPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button
type="button"
variant="outline"
className="h-12 shrink-0"
onClick={() => setAddBrandOpen(true)}
aria-label="Add brand"
>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div> </div>
)} )}
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => {
setLowStockAt(e.target.value)
clearErrors("lowStockAt", "reorderQty")
}}
placeholder="Alert when stock ≤"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => {
setReorderQty(e.target.value)
clearErrors("reorderQty")
}}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div> </div>
</section>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Units &amp; stock</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label> <Label className="text-base" required>Base UOM</Label>
<div className="flex gap-2">
<Select<number | null> <Select<number | null>
value={baseUomId} value={baseUomId}
onValueChange={setBaseUomId} onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
> >
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select base UOM" /> <SelectValue placeholder="Select base UOM" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -904,6 +909,17 @@ export default function NewItemPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button
type="button"
variant="outline"
className="h-12 shrink-0"
onClick={() => setAddUomOpen(true)}
aria-label="Add UOM"
>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div> </div>
{/* The unit size variants are measured in. Picked once here; every size value added {/* The unit size variants are measured in. Picked once here; every size value added
below (custom size variant or a measurement Item Variant) starts in this unit. */} below (custom size variant or a measurement Item Variant) starts in this unit. */}
@@ -1026,11 +1042,88 @@ export default function NewItemPage() {
</p> </p>
</div> </div>
</div> </div>
</section>
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
{/* Stays side by side even on phones: two short numbers read as one rule. */}
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => {
setLowStockAt(e.target.value)
clearErrors("lowStockAt", "reorderQty")
}}
placeholder="Alert at"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => {
setReorderQty(e.target.value)
clearErrors("reorderQty")
}}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
</div>
</section>
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( {(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
{config?.sellingPricesEnabled && ( {config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2> <h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1054,8 +1147,8 @@ export default function NewItemPage() {
</RadioGroup> </RadioGroup>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex min-w-56 flex-1 flex-col gap-2"> <div className="flex flex-col gap-2 sm:min-w-56 sm:flex-1">
<Label className="text-base">Bulk default flat price</Label> <Label className="text-base">Bulk default flat price</Label>
<Input <Input
type="number" type="number"
@@ -1071,6 +1164,7 @@ export default function NewItemPage() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-11 w-full sm:w-auto"
onClick={() => { onClick={() => {
const raw = defaultFlatPrice.trim() const raw = defaultFlatPrice.trim()
if (!raw) return if (!raw) return
@@ -1096,7 +1190,7 @@ export default function NewItemPage() {
)} )}
{config?.warrantyPeriodsEnabled && ( {config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2> <h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1140,7 +1234,7 @@ export default function NewItemPage() {
value: m, value: m,
}))} }))}
> >
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}> <SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" /> <SelectValue placeholder="Select period" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1253,7 +1347,7 @@ export default function NewItemPage() {
)} )}
{config?.itemImagesEnabled && ( {config?.itemImagesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span> Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
@@ -1275,7 +1369,7 @@ export default function NewItemPage() {
type="button" type="button"
onClick={() => removePendingImage(p.url)} onClick={() => removePendingImage(p.url)}
aria-label={`Remove ${p.file.name}`} aria-label={`Remove ${p.file.name}`}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive" className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
> >
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
@@ -1309,7 +1403,7 @@ export default function NewItemPage() {
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */} item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && ( {config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Item variants</h2> <h2 className="text-lg font-semibold text-foreground">Item variants</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1329,7 +1423,7 @@ export default function NewItemPage() {
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-2 sm:gap-3">
{(itemTypes ?? []).map((t) => { {(itemTypes ?? []).map((t) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId) const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two. // Only one dimension may supply the content size, or a variant would have two.
@@ -1339,7 +1433,7 @@ export default function NewItemPage() {
key={t.itemTypeId} key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined} title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn( className={cn(
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", "flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50" blocked ? "opacity-50" : "hover:bg-muted/50"
)} )}
> >
@@ -1362,7 +1456,7 @@ export default function NewItemPage() {
})} })}
{config?.customVariantsEnabled && !customOpen && ( {config?.customVariantsEnabled && !customOpen && (
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}> <Button type="button" variant="outline" className="h-11" onClick={() => setCustomOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
Custom variant Custom variant
</Button> </Button>
@@ -1414,11 +1508,11 @@ export default function NewItemPage() {
/> />
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"} {measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
</label> </label>
<div className="flex gap-2 sm:ml-auto"> <div className="grid grid-cols-2 gap-2 sm:ml-auto sm:flex">
<Button type="button" variant="ghost" onClick={closeCustomVariant}> <Button type="button" variant="ghost" className="h-11 sm:h-9" onClick={closeCustomVariant}>
Cancel Cancel
</Button> </Button>
<Button type="button" onClick={createCustomVariant}> <Button type="button" className="h-11 sm:h-9" onClick={createCustomVariant}>
Add Add
</Button> </Button>
</div> </div>
@@ -1490,9 +1584,15 @@ export default function NewItemPage() {
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}> <Button
type="button"
variant="outline"
className="h-11 shrink-0"
onClick={() => addValue(t.itemTypeId)}
aria-label={`Add ${t.name}`}
>
<Plus className="size-4" /> <Plus className="size-4" />
Add <span className="hidden sm:inline">Add</span>
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -1509,9 +1609,15 @@ export default function NewItemPage() {
placeholder={t.name} placeholder={t.name}
className="h-11 text-base" className="h-11 text-base"
/> />
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}> <Button
type="button"
variant="outline"
className="h-11 shrink-0"
onClick={() => addValue(t.itemTypeId)}
aria-label={`Add ${t.name}`}
>
<Plus className="size-4" /> <Plus className="size-4" />
Add {t.name} <span className="hidden sm:inline">Add {t.name}</span>
</Button> </Button>
</div> </div>
)} )}
@@ -1525,7 +1631,7 @@ export default function NewItemPage() {
<button <button
type="button" type="button"
onClick={() => removeValue(t.itemTypeId, v.label)} onClick={() => removeValue(t.itemTypeId, v.label)}
className="rounded-full p-0.5 hover:bg-muted" className="rounded-full p-1 hover:bg-muted sm:p-0.5"
aria-label={`Remove ${v.label}`} aria-label={`Remove ${v.label}`}
> >
<X className="size-3" /> <X className="size-3" />
@@ -1544,7 +1650,53 @@ export default function NewItemPage() {
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} /> <FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && ( {variants.length > 0 && (
<div className="overflow-x-auto"> <>
{/* Phones: one card per variant — a 6+ column table can't fit a narrow screen. */}
<ul className="flex flex-col gap-3 md:hidden">
{variants.map((variant) => (
<li key={variant.key} className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-base font-semibold wrap-break-word">
{variant.parts.map((p) => p.value.label).join(" / ")}
</p>
<p className="font-mono text-sm break-all text-muted-foreground">{variant.sku}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="-mt-1 -mr-1 shrink-0 text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</div>
<p className="text-sm">
<span className="text-muted-foreground">Content: </span>
{contentLabelFor(variant)}
</p>
{priceMode === "fixed" && (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-3 gap-2">
{(["min", "max", "flat"] as const).map((kind) => (
<div key={kind} className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
{kind === "min" ? "Min" : kind === "max" ? "Max" : "Flat"}
</span>
{priceInput(variant.key, kind, "w-full")}
</div>
))}
</div>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</div>
)}
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -1583,51 +1735,10 @@ export default function NewItemPage() {
</TableCell> </TableCell>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<> <>
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "min", "w-28")}</TableCell>
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "max", "w-28")}</TableCell>
<TableCell className="px-3 py-2.5"> <TableCell className="px-3 py-2.5">
<Input {priceInput(variant.key, "flat", "w-28")}
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::min`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::max`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} /> <FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell> </TableCell>
</> </>
@@ -1649,6 +1760,7 @@ export default function NewItemPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)} )}
</div> </div>
)} )}
@@ -1657,16 +1769,40 @@ export default function NewItemPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> {/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
{variants.length > 0 && (
<p className="text-center text-sm text-muted-foreground sm:mr-auto sm:text-left">
{variants.length} variant{variants.length === 1 ? "" : "s"} will be created
</p>
)}
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" onClick={handleSubmit} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
{submitting ? "Creating…" : "Create Variants"} {submitting ? "Creating…" : "Create Variants"}
</Button> </Button>
</div> </div>
</> </>
)} )}
<AddUomDialog
open={addUomOpen}
onOpenChange={setAddUomOpen}
onCreated={(uom) => {
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
setBaseUomId(uom.uomId)
setSubmitError(null)
}}
/>
<AddBrandDialog
open={addBrandOpen}
onOpenChange={setAddBrandOpen}
onCreated={(brand) => {
setBrands((prev) => [...(prev ?? []), brand])
setBrandId(brand.brandId)
}}
/>
</div> </div>
) )
} }
@@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory,
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 { toast } from "@/components/ui/toast"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field" import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar" import { FilterBar } from "@/components/ui/filter-bar"
@@ -187,7 +188,9 @@ export default function ItemsPage() {
if (!append) setSelected(new Set()) if (!append) setSelected(new Set())
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (cancelled) return
setError(errorMessage(err))
toast.error("Could not load items", errorMessage(err))
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoadingMore(false) if (!cancelled) setLoadingMore(false)
@@ -344,19 +347,30 @@ export default function ItemsPage() {
<h1 className="text-2xl font-bold text-foreground">Items</h1> <h1 className="text-2xl font-bold text-foreground">Items</h1>
<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"> {/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> emphasised on desktop; the secondary actions share a row beneath it on mobile. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
<Link
href="/dashboard/products/new"
className={cn(
buttonVariants({ size: "lg" }),
"order-first col-span-2 h-12 text-base font-semibold shadow-md shadow-primary/25 sm:order-last sm:h-11 sm:px-6"
)}
>
<Plus className="size-5" />
New Item
</Link>
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "min-w-0")}>
<ListTree className="size-5" /> <ListTree className="size-5" />
Category/Brand/UOM <span className="truncate">
<span className="sm:hidden">Masters</span>
<span className="hidden sm:inline">Category/Brand/UOM</span>
</span>
</Link> </Link>
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}> <Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-5" /> <Download className="size-5" />
Export{selected.size > 0 ? ` (${selected.size})` : ""} Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button> </Button>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
</Link>
</div> </div>
</div> </div>
@@ -475,7 +489,7 @@ export default function ItemsPage() {
<Package className="size-12 text-muted-foreground" /> <Package className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p> <p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
{!hasFilters && ( {!hasFilters && (
<Link href="/dashboard/products/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }), "shadow-md shadow-primary/25")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Item New Item
</Link> </Link>
@@ -23,14 +23,15 @@ 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 { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges" import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { SerialRanges } from "@/components/receiving/SerialRanges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */ /** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */
function LineTrackingInfo({ line }: { line: GrnLine }) { function LineTrackingInfo({ line }: { line: GrnLine }) {
const serials = line.serials ?? [] const serials = line.serials ?? []
if (!line.batchNo && serials.length === 0) return null if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null
return ( return (
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground"> <div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
{line.batchNo && ( {line.batchNo && (
@@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) {
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>} {line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
</p> </p>
)} )}
{serials.length > 0 && ( <SerialRanges label="serial number" values={serials.map((s) => s.serialNo)} />
<details> <SerialRanges label="warranty number" values={line.warrantyNumbers.map((w) => w.warrantyNo)} />
<summary className="cursor-pointer select-none">
{serials.length} serial number{serials.length === 1 ? "" : "s"}
</summary>
<ul className="mt-1 grid max-h-48 grid-cols-2 gap-x-4 overflow-y-auto font-mono">
{serials.map((s) => (
<li key={s.serialNo}>{s.serialNo}</li>
))}
</ul>
</details>
)}
</div> </div>
) )
} }
@@ -112,7 +103,13 @@ export default function GrnDetailPage() {
useEffect(() => { useEffect(() => {
if (!Number.isFinite(grnId)) return if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err))) grnsApi
.get(grnId)
.then(setGrn)
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRN", errorMessage(err))
})
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()]) Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
.then(([it, uo]) => { .then(([it, uo]) => {
setItems(it.items) setItems(it.items)
@@ -258,11 +255,6 @@ export default function GrnDetailPage() {
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p> <p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>} {item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
</div> </div>
{line.warrantyNumbers.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} /> <LineTrackingInfo line={line} />
</div> </div>
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
@@ -378,14 +370,6 @@ export default function GrnDetailPage() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>} {item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div> </div>
{line.warrantyNumbers.length > 0 && (
<p
className="mt-1 text-xs text-muted-foreground"
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
>
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} /> <LineTrackingInfo line={line} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell> <TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
@@ -289,7 +289,10 @@ export default function NewGrnPage() {
setVendors(ve.items) setVendors(ve.items)
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived")) setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
}) })
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the GRN form", errorMessage(err))
})
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -318,6 +321,27 @@ export default function NewGrnPage() {
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = warehouses ?? [] const visibleWarehouses = warehouses ?? []
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
function failHeader(message: string) {
setHeaderError(message)
toast.error("Check the GRN details", message)
}
function failScan(message: string) {
setScanError(message)
toast.error("Scan failed", message)
}
function failSubmit(message: string) {
setSubmitError(message)
toast.error("Could not create GRN", message)
}
function failWarranty(lineKey: string, message: string) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message }))
toast.error("Could not fill warranty numbers", message)
}
function switchMode(next: Mode) { function switchMode(next: Mode) {
setMode(next) setMode(next)
setPoId(null) setPoId(null)
@@ -342,7 +366,7 @@ export default function NewGrnPage() {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId) const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty) const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
if (openLines.length === 0) { if (openLines.length === 0) {
setHeaderError("This purchase order has no open (unreceived) lines.") failHeader("This purchase order has no open (unreceived) lines.")
setLines([]) setLines([])
return return
} }
@@ -369,7 +393,7 @@ export default function NewGrnPage() {
}) })
) )
} catch (err) { } catch (err) {
setHeaderError(errorMessage(err)) failHeader(errorMessage(err))
} finally { } finally {
setPoLoading(false) setPoLoading(false)
} }
@@ -403,9 +427,9 @@ export default function NewGrnPage() {
addItemLine(result.itemId, scannedBatchNo) addItemLine(result.itemId, scannedBatchNo)
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`) failScan(`No item or lot found for "${code}"`)
} else { } else {
setScanError(errorMessage(err)) failScan(errorMessage(err))
} }
} finally { } finally {
setScanning(false) setScanning(false)
@@ -526,7 +550,7 @@ export default function NewGrnPage() {
function replaceWarrantyNumbers(lineKey: string, units: number) { function replaceWarrantyNumbers(lineKey: string, units: number) {
const range = warrantyRangeInputs[lineKey] const range = warrantyRangeInputs[lineKey]
if (!range?.from.trim() || !range?.adding.trim()) { if (!range?.from.trim() || !range?.adding.trim()) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" })) failWarranty(lineKey, "Enter both a from number and an adding number")
return return
} }
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1 // "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
@@ -536,14 +560,11 @@ export default function NewGrnPage() {
const to = applyAddingNumber(from, range.adding) const to = applyAddingNumber(from, range.adding)
const seq = buildWarrantyRange(from, to) const seq = buildWarrantyRange(from, to)
if (!seq) { if (!seq) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" })) failWarranty(lineKey, "The computed number must share From's prefix and not come before it")
return return
} }
if (seq.length > units) { if (seq.length > units) {
setWarrantyAddErrors((prev) => ({ failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`)
...prev,
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
}))
return return
} }
setWarrantyAddErrors((prev) => { setWarrantyAddErrors((prev) => {
@@ -636,23 +657,23 @@ export default function NewGrnPage() {
poId: mode === "po" ? poId : null, poId: mode === "po" ? poId : null,
}) })
if (!header.success) { if (!header.success) {
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.") failHeader(header.error.issues[0]?.message ?? "Check the header fields.")
return return
} }
if (mode === "direct" && !vendorId) { if (mode === "direct" && !vendorId) {
setHeaderError("Select a vendor for a direct receipt.") failHeader("Select a vendor for a direct receipt.")
return return
} }
if (mode === "po" && !poId) { if (mode === "po" && !poId) {
setHeaderError("Select a purchase order.") failHeader("Select a purchase order.")
return return
} }
if (branchingEnabled && !effectiveBranchId) { if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this GRN.") failHeader("Select a branch for this GRN.")
return return
} }
if (lines.length === 0) { if (lines.length === 0) {
setSubmitError("Add at least one line.") failSubmit("Add at least one line.")
return return
} }
@@ -680,6 +701,12 @@ export default function NewGrnPage() {
) )
setLineTab(onlyWarrantyErrors ? "warranty" : "lines") setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
setSubmitError("Fix the highlighted lines before submitting.") setSubmitError("Fix the highlighted lines before submitting.")
// The toast names the first problem so it's useful even when that line is scrolled out of view.
const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))]
toast.error(
"Fix the highlighted lines before submitting",
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
)
return return
} }
@@ -16,6 +16,7 @@ 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 { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
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"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -97,7 +98,10 @@ export default function GrnListPage() {
setGrns(res.items) setGrns(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRNs", errorMessage(err))
})
.finally(() => setShowMoreLoading(false)) .finally(() => setShowMoreLoading(false))
} }
@@ -114,7 +118,7 @@ export default function GrnListPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1> <h1 className="text-2xl font-bold text-foreground">Goods Received Notes</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
Receive goods against a purchase order, or record a direct receipt. Receive goods against a purchase order, or record a direct receipt.
</p> </p>
@@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf" import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { maskAccountNumber } from "@/types/bank-deposits" import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales" import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
@@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
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 { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean })
) )
} }
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */ /** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) { function ProfitStatement({ day }: { day: DayEndReportDay }) {
const gross = profitOrLoss(day.grossProfit) const gross = profitOrLoss(day.grossProfit)
@@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) {
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module. * banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
*/ */
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) { function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
const depositPage = useClientPagination(deposits, date)
const remaining = day.closingCashInHand ?? 0 const remaining = day.closingCashInHand ?? 0
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -125,21 +142,15 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} /> <StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} /> <StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} /> <StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
<div <StatCard
className={cn( label="Remaining cash in hand"
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1", value={formatAmount(remaining)}
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border" icon={PiggyBank}
)} hint={remaining > 0 ? "Not yet deposited" : "All cash banked"}
> className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined}
<div className="flex items-start justify-between gap-2"> iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined}
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p> valueClassName={remaining > 0 ? "text-warning" : undefined}
<PiggyBank className={cn("size-4", remaining > 0 ? "text-warning" : "text-muted-foreground")} aria-hidden /> />
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", remaining > 0 ? "text-warning" : "text-foreground")}>
{formatAmount(remaining)}
</p>
<p className="text-xs text-muted-foreground">{remaining > 0 ? "Not yet deposited" : "All cash banked"}</p>
</div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
@@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
{deposits.length === 0 ? ( {deposits.length === 0 ? (
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p> <p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
) : ( ) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{depositPage.pageRows.map((d) => (
<li key={d.bankDepositId} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="font-medium text-primary hover:underline">{d.docNo}</Link>
<span className="font-mono font-semibold tabular-nums">{formatAmount(d.amount)}</span>
</div>
<p className="text-xs text-muted-foreground">
{[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")}
</p>
<p className="text-xs text-muted-foreground">
Slip {d.depositSlipNo ?? "—"} · {formatReportDate(d.depositDate)}
</p>
</li>
))}
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
<span>Total deposited</span>
<span className="font-mono tabular-nums">{formatAmount(day.cashDeposited ?? 0)}</span>
</li>
</ul>
<div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{deposits.map((d) => ( {depositPage.pageRows.map((d) => (
<TableRow key={d.bankDepositId}> <TableRow key={d.bankDepositId}>
<TableCell> <TableCell>
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link> <Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
@@ -184,6 +217,11 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
</div>
{depositPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={depositPage.pagination} onPageChange={depositPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={depositPage.setPageSize} />
)}
</>
)} )}
</div> </div>
) )
@@ -272,6 +310,10 @@ export default function DayEndReportPage() {
const sales = day?.data?.sales ?? [] const sales = day?.data?.sales ?? []
const expenses = day?.data?.expenses ?? [] const expenses = day?.data?.expenses ?? []
const deposits = day?.data?.deposits ?? [] const deposits = day?.data?.deposits ?? []
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
const salesPage = useClientPagination(sales, date)
const expensePage = useClientPagination(expenses, date)
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -305,7 +347,7 @@ export default function DayEndReportPage() {
{!s && !day?.error && ( {!s && !day?.error && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-[84px] rounded-xl" />)} {Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div> </div>
)} )}
@@ -325,27 +367,15 @@ export default function DayEndReportPage() {
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} /> <StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} /> <StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} /> <StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<div <StatCard
className={cn( label="Income"
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1", value={formatSignedAmount(s.income)}
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30" icon={s.income >= 0 ? TrendingUp : TrendingDown}
)} hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"}
> className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
<div className="flex items-start justify-between gap-2"> iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
<p className="text-sm font-semibold text-foreground">Income</p> valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
{s.income >= 0 />
? <TrendingUp className="size-4 text-success" aria-hidden />
: <TrendingDown className="size-4 text-destructive" aria-hidden />}
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", s.income >= 0 ? "text-success" : "text-destructive")}>
{formatSignedAmount(s.income)}
</p>
{s.income < 0 ? (
<p className="text-xs font-semibold text-destructive">Loss — expenses are more than sales</p>
) : (
<p className="text-xs text-muted-foreground">Sales − expenses</p>
)}
</div>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque {s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
@@ -361,7 +391,48 @@ export default function DayEndReportPage() {
{sales.length === 0 ? ( {sales.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p> <p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
) : ( ) : (
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border"> <>
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{salesPage.pageRows.map((row) => (
<li key={`${row.docType}-${row.docId}`} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0">
{row.docType === "Invoice" ? (
<Link href={`/dashboard/sales/invoices/${row.docId}`} className="font-medium text-primary hover:underline">{row.docNo}</Link>
) : (
<span className="font-medium">{row.docNo}</span>
)}
<span className="block truncate text-xs text-muted-foreground">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · {row.docType} · {row.customerName}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-mono font-semibold tabular-nums">{formatAmount(row.total)}</span>
<span className="block text-xs text-muted-foreground">{row.paidBy}</span>
</span>
</div>
<div className="grid grid-cols-4 gap-2 border-t pt-2">
<KV label="Paid">{formatAmount(row.paid)}</KV>
<KV label="Due">{formatAmount(row.due, true)}</KV>
<KV label="Cost">{formatAmount(row.cost)}</KV>
<KV label="Profit" className={cn(row.grossProfit < 0 && "text-destructive")}>{formatAmount(row.grossProfit)}</KV>
</div>
</li>
))}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total</span>
<span className="font-mono tabular-nums">{formatAmount(s.totalSales)}</span>
</div>
<div className="grid grid-cols-4 gap-2 border-t pt-2">
<KV label="Paid">{formatAmount(s.paidAmount)}</KV>
<KV label="Due">{formatAmount(s.dueAmount)}</KV>
<KV label="Cost">{formatAmount(s.costOfGoodsSold)}</KV>
<KV label="Profit">{formatAmount(s.grossProfit)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table> <Table>
<TableHeader className="sticky top-0 bg-card"> <TableHeader className="sticky top-0 bg-card">
<TableRow> <TableRow>
@@ -378,7 +449,7 @@ export default function DayEndReportPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sales.map((row) => ( {salesPage.pageRows.map((row) => (
<TableRow key={`${row.docType}-${row.docId}`}> <TableRow key={`${row.docType}-${row.docId}`}>
<TableCell className="tabular-nums"> <TableCell className="tabular-nums">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
@@ -415,6 +486,10 @@ export default function DayEndReportPage() {
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
{salesPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={salesPage.pagination} onPageChange={salesPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={salesPage.setPageSize} />
)}
</>
)} )}
</div> </div>
@@ -423,6 +498,29 @@ export default function DayEndReportPage() {
{expenses.length === 0 ? ( {expenses.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p> <p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
) : ( ) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{expensePage.pageRows.map((e) => (
<li key={e.expenseId} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0">
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="font-medium text-primary hover:underline">{e.docNo}</Link>
<span className="block truncate text-xs text-muted-foreground">{e.categoryName} · {e.titleName}</span>
{e.payee && <span className="block truncate text-xs text-muted-foreground">Payee: {e.payee}</span>}
</span>
<span className="shrink-0 text-right">
<span className="block font-mono font-semibold tabular-nums">{formatAmount(e.amount)}</span>
<span className="block text-xs text-muted-foreground">Paid {formatAmount(e.paidAmount)}</span>
</span>
</div>
</li>
))}
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
<span>Total</span>
<span className="font-mono tabular-nums">{formatAmount(s.expenseTotal)}</span>
</li>
</ul>
<div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -435,7 +533,7 @@ export default function DayEndReportPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{expenses.map((e) => ( {expensePage.pageRows.map((e) => (
<TableRow key={e.expenseId}> <TableRow key={e.expenseId}>
<TableCell> <TableCell>
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link> <Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
@@ -456,6 +554,11 @@ export default function DayEndReportPage() {
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
</div>
{expensePage.pagination.totalItems > 10 && (
<PaginationFooter pagination={expensePage.pagination} onPageChange={expensePage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={expensePage.setPageSize} />
)}
</>
)} )}
</div> </div>
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2> <h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p> <p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="der-from">From</Label> <Label htmlFor="der-from">From</Label>
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-44" /> <Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="der-to">To</Label> <Label htmlFor="der-to">To</Label>
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-44" /> <Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div> </div>
<Button onClick={downloadRange} disabled={!range?.data || downloading !== null}> <Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<FileDown className="size-4" /> <FileDown className="size-4" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"} {downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button> </Button>
@@ -493,7 +596,7 @@ export default function DayEndReportPage() {
{!rangeError && range?.data && ( {!rangeError && range?.data && (
<> <>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} /> <StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} /> <StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard <StatCard
@@ -510,9 +613,64 @@ export default function DayEndReportPage() {
/> />
</div> </div>
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} /> <DayEndIncomeChart days={range.data.days} />
</div>
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border"> {/* Phones: one card per day (newest first), tap to show that day above. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => d.date && setDate(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table> <Table>
<TableHeader className="sticky top-0 bg-card"> <TableHeader className="sticky top-0 bg-card">
<TableRow> <TableRow>
@@ -531,7 +689,7 @@ export default function DayEndReportPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{[...range.data.days].reverse().map((d) => ( {dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above"> <TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
<TableCell>{formatReportDate(d.date)}</TableCell> <TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell> <TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
@@ -568,6 +726,9 @@ export default function DayEndReportPage() {
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</> </>
)} )}
</section> </section>
@@ -2,17 +2,17 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react" import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config" import { salesConfigApi } from "@/lib/api/sales-config"
import { salesDayEndApi } from "@/lib/api/sales-day-end"
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 { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog" import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
@@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer" 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 { toast } from "@/components/ui/toast"
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
type SalesDateRange = "All" | "Today" | "7d" | "30d" type SalesDateRange = "All" | "Today" | "7d" | "30d"
@@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "Paid", label: "Fully paid" }, { value: "Paid", label: "Fully paid" },
] ]
export default function SalesDayEndPage() { export default function ApprovedInvoicesPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([]) const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [businessDate, setBusinessDate] = useState(todayIso())
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null) // Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [report, setReport] = useState<SalesDayEnd | null>(null)
const [closing, setClosing] = useState(false)
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
// business date above): a cashier (null follows the cashier picked above, "All" shows every
// cashier's sales) and a date range. // cashier's sales) and a date range.
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null) const [salesCashier, setSalesCashier] = useState<number | "All">("All")
const effectiveSalesCashier = salesCashier ?? cashierUserId const effectiveSalesCashier = salesCashier
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today") const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange) const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All") const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
const [salesSearchInput, setSalesSearchInput] = useState("") const [salesSearchInput, setSalesSearchInput] = useState("")
@@ -135,30 +120,10 @@ export default function SalesDayEndPage() {
useEffect(() => { useEffect(() => {
usersApi usersApi
.list({ pageSize: 200 }) .list({ pageSize: 200 })
.then((res) => { .then((res) => setCashiers(res.items))
setCashiers(res.items)
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
})
.catch(() => {}) .catch(() => {})
}, []) }, [])
function loadPreview() {
if (!cashierUserId) return
setReport(null)
setPreviewError(null)
setPreviewLoading(true)
salesDayEndApi
.preview(cashierUserId, businessDate)
.then(setPreview)
.catch((err) => setPreviewError(errorMessage(err)))
.finally(() => setPreviewLoading(false))
}
useEffect(() => {
loadPreview()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate])
// Debounced search; a new term starts again from page 1. // Debounced search; a new term starts again from page 1.
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -170,7 +135,6 @@ export default function SalesDayEndPage() {
}, [salesSearchInput]) }, [salesSearchInput])
useEffect(() => { useEffect(() => {
if (effectiveSalesCashier === null) return
salesApi salesApi
.listInvoices({ .listInvoices({
page: salesPage, page: salesPage,
@@ -186,6 +150,41 @@ export default function SalesDayEndPage() {
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) })) .catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize]) }, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
// Stat cards: the same filters as the table, totalled over every match (not one page). Keyed
// like the table so a change of dropdown — or a payment — refreshes them.
const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|")
const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null)
const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined
// If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed
// from the loaded page and labelled as such — rather than disappearing.
const pageTotals: SalesInvoiceListTotals | null = sales
? {
count: salesPagination?.totalItems ?? sales.length,
netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0),
paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0),
balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0),
unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length,
overdueCount: sales.filter(isOverdue).length,
}
: null
const pageOnly = totals === null
const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals
useEffect(() => {
salesApi
.invoiceTotals({
status: "Posted",
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
q: salesQuery || undefined,
})
.then((data) => setTotalsResult({ key: totalsKey, data }))
// Cards are a summary — a failure blanks them (the table reports its own errors).
.catch(() => setTotalsResult({ key: totalsKey, data: null }))
}, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery])
/** Re-read the table (after a payment); ticks are cleared since balances changed. */ /** Re-read the table (after a payment); ticks are cleared since balances changed. */
function reloadSales() { function reloadSales() {
setSelected(new Map()) setSelected(new Map())
@@ -198,12 +197,6 @@ export default function SalesDayEndPage() {
setSelected(new Map()) setSelected(new Map())
} }
function changeCashier(userId: number | null) {
setCashierUserId(userId)
setSalesCashier(null)
resetSalesView()
}
function changeSalesCashier(value: number | "All") { function changeSalesCashier(value: number | "All") {
setSalesCashier(value) setSalesCashier(value)
resetSalesView() resetSalesView()
@@ -224,23 +217,6 @@ export default function SalesDayEndPage() {
resetSalesView() resetSalesView()
} }
async function handleClose() {
if (!cashierUserId) return
setClosing(true)
try {
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
setReport(result)
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
loadPreview()
} catch (err) {
toast.error("Could not close the day", errorMessage(err))
} finally {
setClosing(false)
}
}
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */ /** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
@@ -262,220 +238,28 @@ export default function SalesDayEndPage() {
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4 sm:gap-6">
<div className="flex items-center gap-3"> <div className="flex items-start gap-3 sm:items-center">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }), "shrink-0")}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Day End</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Approved Invoices</h1>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger. Approved sales invoices — view them and take payments, one at a time or several of one customer together.
</p> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3"> <InvoiceTotalsCards
<div className="flex flex-col gap-2"> totals={cardTotals}
<Label className="text-base">Cashier</Label> pageOnly={pageOnly}
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}> scope={[
<SelectTrigger className="h-12! w-full text-base"> salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
<SelectValue placeholder="Select cashier" /> salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "",
</SelectTrigger> paymentFilters.find((p) => p.value === salesPayment)?.label ?? "",
<SelectContent> ].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")}
{cashiers.map((c) => (
<SelectItem key={c.userId} value={c.userId} className="text-base">
{c.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Business date</Label>
<Input
type="date"
value={businessDate}
max={todayIso()}
onChange={(e) => setBusinessDate(e.target.value)}
className="h-12 text-base"
/> />
</div>
</div>
{previewLoading && <Skeleton className="h-40 w-full" />}
{previewError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{previewError}</div>
)}
{!previewLoading && preview && !report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
{preview.alreadyClosed ? (
<div className="flex items-start gap-3 rounded-lg border border-success/30 bg-success/5 p-4 text-base text-success">
<CheckCircle2 className="size-5 shrink-0" />
<div>
<p className="font-semibold">{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.</p>
<p className="text-sm text-success/80">{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.</p>
</div>
</div>
) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p className="font-semibold">Still Draft — post or cancel these before closing.</p>
<ul className="list-inside list-disc text-sm">
{preview.draftSlipsBlockingClose.map((s) => (
<li key={`slip-${s.salesSlipId}`}>
{s.slipNo} — {s.grandTotal.toFixed(2)}
</li>
))}
{preview.draftBundleSalesBlockingClose.map((b) => (
<li key={`bundle-${b.bundleSaleId}`}>
{b.bundleNo} — {b.grandTotal.toFixed(2)}
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-base text-muted-foreground">
{preview.slipCount === 0 && preview.bundleCount === 0
? preview.invoices.invoiceCount > 0
? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).`
: "No sales slips or bundle sales for this cashier on this date."
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Subtotal" value={preview.subtotal} />
<Stat label="Discount" value={preview.discountTotal} />
<Stat label="Tax" value={preview.taxTotal} />
<Stat label="Grand total" value={preview.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={preview.paidTotal} />
<Stat label="On account (balance)" value={preview.balanceTotal} />
</div>
<InvoiceSummaryPanel invoices={preview.invoices} />
{!preview.alreadyClosed && (
<div className="flex justify-end">
<Button
size="lg"
onClick={handleClose}
disabled={closing || preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 || !cashierUserId}
>
<Lock className="size-5" />
{closing ? "Closing…" : "Close day"}
</Button>
</div>
)}
{preview.alreadyClosed && (
<div className="flex justify-end">
<Button size="lg" variant="outline" onClick={handleClose} disabled={closing}>
<ReceiptText className="size-5" />
{closing ? "Loading…" : "View full report"}
</Button>
</div>
)}
</div>
)}
{report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-1 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">{report.docNo}</h2>
<p className="text-sm text-muted-foreground">
{cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()}
</p>
</div>
{report.glJournalNo && (
<div className="text-sm text-muted-foreground">
GL journal <span className="font-medium text-foreground">{report.glJournalNo}</span> posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
<Stat label="Slips" value={report.slipCount} isCount />
<Stat label="Bundles" value={report.bundleCount} isCount />
<Stat label="Subtotal" value={report.subtotal} />
<Stat label="Discount" value={report.discountTotal} />
<Stat label="Tax" value={report.taxTotal} />
<Stat label="Grand total" value={report.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={report.paidTotal} />
<Stat label="On account (balance)" value={report.balanceTotal} />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
{report.itemBreakdown.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
) : (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{report.itemBreakdown.map((line) => (
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{line.qty} × {line.revenue.toFixed(2)}
</span>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
{report.slipNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Slips included</h3>
<p className="text-sm text-muted-foreground">{report.slipNumbers.join(", ")}</p>
</div>
)}
{report.bundleNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Bundle sales included</h3>
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
</div>
)}
<InvoiceSummaryPanel invoices={report.invoices} />
</div>
)}
<SalesTable <SalesTable
rows={sales} rows={sales}
@@ -634,24 +418,28 @@ function SalesTable({
const visible = rows ?? [] const visible = rows ?? []
const filtered = paymentFilter !== "All" || search.trim().length > 0 const filtered = paymentFilter !== "All" || search.trim().length > 0
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
return ( return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]"> <div className={cn(PANEL, "flex flex-col gap-4")}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Sales</h2> <h2 className="text-lg font-semibold text-foreground">Sales</h2>
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p> <p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> {/* Phones: search on top, cashier full-width, then date + payment side by side. */}
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-4">
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="col-span-2 h-11 text-base sm:col-span-1 sm:h-10 sm:text-sm lg:order-last"
/>
<div className="col-span-2 sm:col-span-1">
<Select<number | "All" | null> <Select<number | "All" | null>
value={cashierFilter} value={cashierFilter}
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }} onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]} items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
> >
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier"> <SelectTrigger className="h-11! w-full text-sm sm:h-10!" aria-label="Cashier">
<SelectValue placeholder="Cashier" /> <SelectValue placeholder="Cashier" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -661,12 +449,13 @@ function SalesTable({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<Select<SalesDateRange> <Select<SalesDateRange>
value={dateRange} value={dateRange}
onValueChange={(v) => onDateRangeChange(v ?? "Today")} onValueChange={(v) => onDateRangeChange(v ?? "All")}
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))} items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
> >
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range"> <SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" /> <CalendarDays className="size-4 text-muted-foreground" />
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -681,7 +470,7 @@ function SalesTable({
onValueChange={(v) => onPaymentFilterChange(v ?? "All")} onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))} items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
> >
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status"> <SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Payment status">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -690,12 +479,6 @@ function SalesTable({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="h-10 text-sm"
/>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>} {error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
@@ -709,17 +492,18 @@ function SalesTable({
)} )}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> // Sticky on phones so "Pay selected" stays reachable while ticking rows further down.
<div className="sticky bottom-0 z-10 flex flex-col gap-2 rounded-lg border bg-card px-4 py-3 shadow-lg ring-1 ring-primary/20 sm:static sm:flex-row sm:items-center sm:justify-between sm:bg-primary/5 sm:shadow-none sm:ring-0">
<p className="text-sm"> <p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "} <span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "} <span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span> <span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
</p> </p>
<div className="flex gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex">
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}> <Button type="button" variant="outline" size="sm" className="h-10 sm:h-8" onClick={onClearSelection}>
Clear Clear
</Button> </Button>
<Button type="button" size="sm" onClick={onPaySelected}> <Button type="button" size="sm" className="h-10 sm:h-8" onClick={onPaySelected}>
<CircleDollarSign className="size-4" /> <CircleDollarSign className="size-4" />
Pay selected Pay selected
</Button> </Button>
@@ -729,14 +513,22 @@ function SalesTable({
{!error && rows !== null && visible.length > 0 && ( {!error && rows !== null && visible.length > 0 && (
<> <>
{/* Mobile: stacked rows. */} {/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so
<div className="flex flex-col gap-1.5 md:hidden"> nothing is squeezed. Tapping the details opens the invoice. */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:hidden">
{visible.map((row) => ( {visible.map((row) => (
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm"> <div
{renderSelectBox(row)} key={row.salesInvoiceId}
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"> className={cn(
"flex flex-col gap-3 rounded-lg border p-3 text-sm",
selected.has(row.salesInvoiceId) && "border-primary/50 bg-primary/5"
)}
>
<div className="flex items-start gap-3">
<div className="pt-0.5">{renderSelectBox(row)}</div>
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-start justify-between gap-2 text-left">
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-medium">{row.invoiceNo}</span> <span className="block truncate font-semibold">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground"> <span className="block truncate text-muted-foreground">
{row.customerSnapshotName} {row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`} {showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
@@ -748,29 +540,39 @@ function SalesTable({
</span> </span>
</span> </span>
</span> </span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} /> <SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
</span>
</button> </button>
</div>
<div className="flex items-end justify-between gap-2 border-t pt-2">
<div className="grid grid-cols-3 gap-x-3 text-xs">
<span className="text-muted-foreground">Net</span>
<span className="text-muted-foreground">Paid</span>
<span className="text-muted-foreground">Due</span>
<span className="font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<span className="font-mono tabular-nums">{row.totals.paidAmount.toFixed(2)}</span>
<span className={cn("font-mono tabular-nums", row.totals.balanceAmount > 0 && "font-semibold text-destructive")}>
{row.totals.balanceAmount.toFixed(2)}
</span>
</div>
{renderActions(row)} {renderActions(row)}
</div> </div>
</div>
))} ))}
</div> </div>
{/* Desktop: table. */} {/* Desktop (lg+): table. Date and Paid only appear from xl, where there is room for every column. */}
<div className="hidden overflow-x-auto rounded-xl border md:block"> <div className="hidden overflow-x-auto rounded-xl border lg:block">
<Table className="text-base"> <Table className="text-sm xl:text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-12 w-10 px-3" /> <TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead> <TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead> <TableHead className="h-12 px-3 text-sm">Customer</TableHead>
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>} {showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
<TableHead className="h-12 px-3 text-sm">Date</TableHead> <TableHead className="hidden h-12 px-3 text-sm xl:table-cell">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Due</TableHead> <TableHead className="h-12 px-3 text-sm">Due</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead> <TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead> <TableHead className="hidden h-12 px-3 text-right text-sm xl:table-cell">Paid</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead> <TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -790,16 +592,16 @@ function SalesTable({
{renderSelectBox(row)} {renderSelectBox(row)}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell> <TableCell className="max-w-48 truncate px-3 py-3.5" title={row.customerSnapshotName}>{row.customerSnapshotName}</TableCell>
{showCashier && ( {showCashier && (
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell> <TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
)} )}
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell> <TableCell className="hidden px-3 py-3.5 xl:table-cell">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}> <TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()} {new Date(row.invoiceDate).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell> <TableCell className="hidden px-3 py-3.5 text-right tabular-nums xl:table-cell">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell <TableCell
className={cn( className={cn(
"px-3 py-3.5 text-right tabular-nums", "px-3 py-3.5 text-right tabular-nums",
@@ -833,52 +635,61 @@ function SalesTable({
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
/> />
)} )}
{/* Invoices counts every match; the amounts are for the rows on this page. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
<Stat label="Net (this page)" value={netTotal} emphasize />
<Stat label="Paid (this page)" value={paidTotal} />
<Stat label="Remaining (this page)" value={balanceTotal} />
</div>
</> </>
)} )}
</div> </div>
) )
} }
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have /** Shared card shell — tighter padding on phones, roomier from `sm` up. */
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5"
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
if (invoices.invoiceCount === 0) return null
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/20 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">Sales invoices (not part of this close)</h3>
<p className="text-xs text-muted-foreground">
Invoices post their own ledger entry when confirmed — shown here for visibility only.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Invoices" value={invoices.invoiceCount} isCount />
<Stat label="Subtotal" value={invoices.invoiceSubtotal} />
<Stat label="Discount" value={invoices.invoiceDiscountTotal} />
<Stat label="Tax" value={invoices.invoiceTaxTotal} />
<Stat label="Grand total" value={invoices.invoiceGrandTotal} emphasize />
</div>
<p className="text-sm text-muted-foreground">{invoices.invoiceNumbers.join(", ")}</p>
</div>
)
}
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) { /**
* Top-of-page totals for whatever the dropdowns/search currently select — across every matching
* invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available).
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/
function InvoiceTotalsCards({
totals, scope, pageOnly,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
if (totals === undefined) {
return ( return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<span className="text-xs text-muted-foreground">{label}</span> {Array.from({ length: 5 }).map((_, i) => (
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}> <Skeleton key={i} className="h-24 rounded-2xl" />
{isCount ? value : value.toFixed(2)} ))}
</span> </div>
)
}
if (totals === null) return null
return (
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">
Totals for: {scope}
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} />
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" />
<StatCard
label="Balance due"
value={formatAmount(totals.balanceTotal)}
icon={Hourglass}
hint="Still to collect"
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
/>
<StatCard
label="Overdue"
value={totals.overdueCount}
icon={AlertTriangle}
hint="Past due date, not fully paid"
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
/>
</div>
</div> </div>
) )
} }
@@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config" import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils" import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
@@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="font-medium text-foreground">{line.description}</div> <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> <div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
{(line.serials ?? []).length > 0 && ( {(line.serials ?? []).length > 0 && (
<div className="mt-0.5 font-mono text-xs text-muted-foreground"> // From – To runs, not one entry per unit (full list on hover).
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")} <div
className="mt-0.5 font-mono text-xs break-all text-muted-foreground"
title={(line.serials ?? []).map((s) => s.serialNo).join(", ")}
>
S/N {formatSerialRanges(toSerialRanges((line.serials ?? []).map((s) => s.serialNo)), 3)}
</div> </div>
)} )}
</td> </td>
@@ -154,7 +154,8 @@ export default function SalesInvoicesPage() {
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage]) }, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => { useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) // Includes the cash customer so its (walk-in) invoices can be filtered too.
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([])) warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, []) }, [])
@@ -24,8 +24,8 @@ const sections = [
icon: PackageX, icon: PackageX,
}, },
{ {
title: "Day End", title: "Approved Invoices",
description: "Close a cashier's day and post it to the ledger.", description: "Approved sales invoices — view them and take payments.",
href: "/dashboard/sales/day-end", href: "/dashboard/sales/day-end",
icon: Lock, icon: Lock,
}, },
@@ -3,15 +3,14 @@
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 { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" import { Pencil, Plus, Power, Search, ShieldCheck, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
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 { Role } from "@/types/rbac" import { Role } from "@/types/rbac"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
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 { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -72,14 +71,15 @@ export default function RolesPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [search]) }, [search])
async function handleDelete(role: Role) { async function toggleStatus(role: Role) {
const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active"
setActionPendingId(role.roleId) setActionPendingId(role.roleId)
try { try {
await rolesApi.remove(role.roleId) await rolesApi.updateStatus(role.roleId, next)
toast.success("Role deleted", `${role.code} has been removed.`) toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`)
load() load()
} catch (err) { } catch (err) {
toast.error("Could not delete role", errorMessage(err)) toast.error("Could not update role status", errorMessage(err))
} finally { } finally {
setActionPendingId(null) setActionPendingId(null)
} }
@@ -236,29 +236,26 @@ export default function RolesPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" className={cn(
aria-label={`Delete ${r.code}`} r.status === "Active"
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`} ? "text-destructive hover:bg-destructive/10"
disabled={r.isSystemRole || actionPendingId === r.roleId} : "text-success hover:bg-success/10"
/> )}
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
title={
r.isSystemRole
? "System roles can't be deactivated"
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
} }
disabled={r.isSystemRole || actionPendingId === r.roleId}
onClick={() => toggleStatus(r)}
> >
<Trash2 className="size-4" /> <Power className="size-4" />
</AlertDialogTrigger> </Button>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div> </div>
</div> </div>
</div> </div>
@@ -347,29 +344,26 @@ export default function RolesPage() {
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" className={cn(
aria-label={`Delete ${r.code}`} r.status === "Active"
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`} ? "text-destructive hover:bg-destructive/10"
disabled={r.isSystemRole || actionPendingId === r.roleId} : "text-success hover:bg-success/10"
/> )}
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
title={
r.isSystemRole
? "System roles can't be deactivated"
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
} }
disabled={r.isSystemRole || actionPendingId === r.roleId}
onClick={() => toggleStatus(r)}
> >
<Trash2 className="size-4" /> <Power className="size-4" />
</AlertDialogTrigger> </Button>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react" import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-react"
import { auditLogsApi } from "@/lib/api/audit-logs" import { auditLogsApi } from "@/lib/api/audit-logs"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
@@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -36,6 +39,15 @@ export default function UserDetailPage() {
const [statusPending, setStatusPending] = useState(false) const [statusPending, setStatusPending] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [displayName, setDisplayName] = useState("")
const [email, setEmail] = useState("")
const [mobileNumber, setMobileNumber] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [sendingResetLink, setSendingResetLink] = useState(false)
useEffect(() => { useEffect(() => {
if (!Number.isFinite(userId)) return if (!Number.isFinite(userId)) return
setLoadError(null) setLoadError(null)
@@ -85,7 +97,10 @@ export default function UserDetailPage() {
try { try {
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus }) const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
setUser(updated) setUser(updated)
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated") toast.success(
nextStatus === "Active" ? "User activated" : "User deactivated",
nextStatus === "Inactive" ? "They can no longer sign in." : undefined
)
} catch (err) { } catch (err) {
toast.error("Could not update status", errorMessage(err)) toast.error("Could not update status", errorMessage(err))
} finally { } finally {
@@ -93,6 +108,54 @@ export default function UserDetailPage() {
} }
} }
function openEdit() {
if (!user) return
setDisplayName(user.displayName)
setEmail(user.email ?? "")
setMobileNumber("")
setEditErrors({})
setEditOpen(true)
}
async function handleSave() {
if (!user) return
const nextErrors: Record<string, string> = {}
if (!displayName.trim()) nextErrors.displayName = "Display name is required"
if (!email.trim()) nextErrors.email = "Email is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
const updated = await usersApi.update(user.userId, {
displayName: displayName.trim(),
email: email.trim(),
mobileNumber: mobileNumber.trim() || null,
})
setUser(updated)
setEditOpen(false)
toast.success("User updated", `${updated.displayName} — changes saved to both this app and the login system.`)
} catch (err) {
toast.error("Could not update user", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleSendResetLink() {
if (!user) return
setSendingResetLink(true)
try {
const result = await usersApi.sendResetLink(user.userId)
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
toast.success("Reset link sent", `Emailed to ${user.email}. Expires at ${expiry}.`)
} catch (err) {
toast.error("Could not send reset link", errorMessage(err))
} finally {
setSendingResetLink(false)
}
}
return ( 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">
@@ -112,6 +175,15 @@ export default function UserDetailPage() {
<p className="text-base text-muted-foreground">{user.displayName}</p> <p className="text-base text-muted-foreground">{user.displayName}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button variant="outline" onClick={handleSendResetLink} disabled={sendingResetLink || !user.email}>
<KeyRound className="size-4" />
{sendingResetLink ? "Sending…" : "Send reset link"}
</Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}> <span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
{user.status === "Active" ? "Active" : "Inactive"} {user.status === "Active" ? "Active" : "Inactive"}
@@ -124,6 +196,7 @@ export default function UserDetailPage() {
/> />
</div> </div>
</div> </div>
</div>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6"> <section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4"> <div className="flex items-center gap-2.5 border-b border-border pb-4">
@@ -264,6 +337,39 @@ export default function UserDetailPage() {
)} )}
</div> </div>
</section> </section>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit {user.username}</DialogTitle>
<DialogDescription>Updates both this app&apos;s record and the login system&apos;s identity.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.displayName}>
<FieldLabel htmlFor="user-display-name">Display name</FieldLabel>
<Input id="user-display-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} aria-invalid={!!editErrors.displayName} />
<FieldError errors={[editErrors.displayName ? { message: editErrors.displayName } : undefined]} />
</Field>
<Field data-invalid={!!editErrors.email}>
<FieldLabel htmlFor="user-email">Email</FieldLabel>
<Input id="user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!editErrors.email} />
<FieldError errors={[editErrors.email ? { message: editErrors.email } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="user-mobile">Mobile number (optional)</FieldLabel>
<Input id="user-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} placeholder="Leave blank to keep unchanged" />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -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 { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" import { KeyRound, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -45,6 +45,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [resetLinkPendingId, setResetLinkPendingId] = 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)
@@ -105,6 +106,19 @@ export default function UsersPage() {
} }
} }
async function handleSendResetLink(u: ManagedUser) {
setResetLinkPendingId(u.userId)
try {
const result = await usersApi.sendResetLink(u.userId)
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
toast.success("Reset link sent", `Emailed to ${u.email}. Expires at ${expiry}.`)
} catch (err) {
toast.error("Could not send reset link", errorMessage(err))
} finally {
setResetLinkPendingId(null)
}
}
return ( 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">
@@ -234,14 +248,6 @@ export default function UsersPage() {
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm"> <div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<span className="text-muted-foreground">{u.roleName ?? "—"}</span> <span className="text-muted-foreground">{u.roleName ?? "—"}</span>
<div className="flex items-center gap-1" data-no-row-nav> <div className="flex items-center gap-1" data-no-row-nav>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -250,6 +256,17 @@ export default function UsersPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Send reset link to ${u.username}`}
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
disabled={resetLinkPendingId === u.userId || !u.email}
onClick={() => handleSendResetLink(u)}
>
<KeyRound className="size-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -324,14 +341,6 @@ export default function UsersPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -340,6 +349,17 @@ export default function UsersPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Send reset link to ${u.username}`}
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
disabled={resetLinkPendingId === u.userId || !u.email}
onClick={() => handleSendResetLink(u)}
>
<KeyRound className="size-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -33,7 +33,7 @@ const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
/** Friendly names for the document-type prefixes a serial movement references. */ /** Friendly names for the document-type prefixes a serial movement references. */
const DOC_LABEL: Record<string, string> = { const DOC_LABEL: Record<string, string> = {
GRN: "Goods receipt", GRN: "Goods received",
SI: "Sales invoice", SI: "Sales invoice",
SSL: "Sales slip", SSL: "Sales slip",
BND: "Bundle sale", BND: "Bundle sale",
+7 -19
View File
@@ -14,6 +14,7 @@ import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn" import { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { StatCard } from "@/components/ui/stat-card"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@@ -27,21 +28,6 @@ import BarChart from "@/components/ui/bar-chart"
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function VendorDetailPage() { export default function VendorDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const vendorId = Number(params.id) const vendorId = Number(params.id)
@@ -293,11 +279,13 @@ export default function VendorDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> {/* Fixed-share columns so the three cards stay compact on wide screens instead of each taking a third. */}
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} /> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} /> <StatCard icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip <StatCard icon={PackageCheck} label="Goods received (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatCard
icon={Truck} icon={Truck}
className="col-span-2 sm:col-span-1"
label="Last delivery" label="Last delivery"
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"} value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
/> />
@@ -33,6 +33,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
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 { limitPhoneInput, phoneError } from "@/lib/validations/phone"
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH" /** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
* so an empty/punctuation-only name still yields a usable base. */ * so an empty/punctuation-only name still yields a usable base. */
@@ -153,13 +154,18 @@ export default function WarehousesPage() {
async function handleCreate() { async function handleCreate() {
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required" if (!name.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(phone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required" if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create warehouse", Object.values(nextErrors)[0])
return
}
setSubmitting(true) setSubmitting(true)
try { try {
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId }) const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
closeCreate() closeCreate()
load() load()
@@ -187,13 +193,18 @@ export default function WarehousesPage() {
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
if (!editCode.trim()) nextErrors.code = "Code is required" if (!editCode.trim()) nextErrors.code = "Code is required"
if (!editName.trim()) nextErrors.name = "Warehouse name is required" if (!editName.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(editPhone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required" if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
setEditErrors(nextErrors) setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) {
toast.error("Could not update warehouse", Object.values(nextErrors)[0])
return
}
setEditSubmitting(true) setEditSubmitting(true)
try { try {
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId }) const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId })
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`) toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
setEditTarget(null) setEditTarget(null)
load() load()
@@ -262,9 +273,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel> <FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" /> <Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
</Field> </Field>
<Field> <Field data-invalid={!!errors.phone}>
<FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel> <FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel>
<Input id="wh-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 …" /> <Input
id="wh-phone"
type="tel"
inputMode="tel"
maxLength={20}
value={phone}
onChange={(e) => { setPhone(limitPhoneInput(e.target.value)); setErrors(dropError("phone")) }}
onBlur={() => { const p = phoneError(phone); if (p) setErrors((prev) => ({ ...prev, phone: p })) }}
placeholder="0771234567 or +94771234567"
aria-invalid={!!errors.phone}
/>
<FieldError errors={[errors.phone ? { message: errors.phone } : undefined]} />
</Field> </Field>
{warehouseBranchingEnabled && ( {warehouseBranchingEnabled && (
<Field data-invalid={!!errors.branchId}> <Field data-invalid={!!errors.branchId}>
@@ -556,9 +578,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel> <FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" /> <Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
</Field> </Field>
<Field> <Field data-invalid={!!editErrors.phone}>
<FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel> <FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel>
<Input id="wh-edit-phone" value={editPhone} onChange={(e) => setEditPhone(e.target.value)} placeholder="+94 …" /> <Input
id="wh-edit-phone"
type="tel"
inputMode="tel"
maxLength={20}
value={editPhone}
onChange={(e) => { setEditPhone(limitPhoneInput(e.target.value)); setEditErrors(dropError("phone")) }}
onBlur={() => { const p = phoneError(editPhone); if (p) setEditErrors((prev) => ({ ...prev, phone: p })) }}
placeholder="0771234567 or +94771234567"
aria-invalid={!!editErrors.phone}
/>
<FieldError errors={[editErrors.phone ? { message: editErrors.phone } : undefined]} />
</Field> </Field>
{warehouseBranchingEnabled && ( {warehouseBranchingEnabled && (
<Field data-invalid={!!editErrors.branchId}> <Field data-invalid={!!editErrors.branchId}>
@@ -0,0 +1,200 @@
"use client"
import { Suspense, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { authApi } from "@/lib/api/auth"
import { errorMessage } from "@/lib/error-map"
import { Check, Eye, EyeOff } from "lucide-react"
// Destination of the 15-minute reset-link email (ERP_Auth_Service's RecoveryManagerService.
// ForgotPassword, ResetLink branch): `{FrontendUrl}/reset-password?token=...`. Distinct from
// /login/forgot/reset, which is the OTP-based self-service flow reached by typing an email in
// — this page only ever arrives via that emailed link's token.
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<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 ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<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 passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
if (!token) {
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Link invalid</h1>
<p className="mt-3 text-base text-muted-foreground">
This reset link is missing its token. Request a new one from the sign-in page.
</p>
</div>
<div className="mt-8 text-center">
<Link href="/login/forgot" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Request a new link
</Link>
</div>
</AuthShell>
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!allValid || !token) return
setSubmitting(true)
setSubmitError(null)
try {
await authApi.resetPasswordWithToken({ resetToken: token, newPassword: password, confirmPassword: confirm })
setShowSuccess(true)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
<p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div>
<form noValidate className="mt-10 space-y-6" onSubmit={handleSubmit}>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{submitError}</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative mt-2">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Enter your new password"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground">Confirm Password</label>
<div className="relative mt-2">
<Input
id="confirm"
type={showConfirm ? "text" : "password"}
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Confirm your new password"
aria-invalid={!passwordsMatch && confirm.length > 0}
/>
<button
type="button"
onClick={() => setShowConfirm((s) => !s)}
aria-label={showConfirm ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div>
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Changing password…" : "Change password"}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link>
</div>
</form>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
<Check className="size-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold">Password changed successfully</DialogTitle>
<DialogDescription className="mt-2">Your password has been updated. You can now sign in with your new password.</DialogDescription>
</div>
</div>
</DialogHeader>
<Button onClick={() => router.push("/login")} className="mx-auto mt-6 h-12 w-4/5 rounded-full text-base font-semibold">
Return to sign in
</Button>
</DialogContent>
</Dialog>
</AuthShell>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<AuthShell><Skeleton className="h-96 w-full" /></AuthShell>}>
<ResetPasswordContent />
</Suspense>
)
}
@@ -46,7 +46,6 @@ import {
Receipt, Receipt,
ReceiptText, ReceiptText,
Scale, Scale,
ScanLine,
ScrollText, ScrollText,
Settings, Settings,
ShieldCheck, ShieldCheck,
@@ -62,12 +61,8 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
const SERIALS_HREF = "/dashboard/stock/serials"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore // `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs) // (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
@@ -109,8 +104,6 @@ const navItems: {
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag }, { title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
], ],
}, },
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
{ {
title: "Procurement", title: "Procurement",
code: "procurement", code: "procurement",
@@ -159,9 +152,6 @@ const navItems: {
children: [ children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText }, { title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch }, { title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign }, { title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight }, { title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal }, { title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
@@ -250,7 +240,6 @@ function SidebarContent({
onClose, onClose,
pathname, pathname,
isMobile, isMobile,
approvalCount,
}: { }: {
items: typeof navItems items: typeof navItems
collapsed: boolean collapsed: boolean
@@ -258,8 +247,6 @@ function SidebarContent({
onClose?: () => void onClose?: () => void
pathname: string pathname: string
isMobile: boolean isMobile: boolean
/** Requests the user can approve — the badge on Approvals. */
approvalCount: number
}) { }) {
const iconOnly = !isMobile && collapsed const iconOnly = !isMobile && collapsed
@@ -381,11 +368,6 @@ function SidebarContent({
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")} className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/> />
{!iconOnly && <span className="flex-1">{item.title}</span>} {!iconOnly && <span className="flex-1">{item.title}</span>}
{item.code === "approvals" && approvalCount > 0 && (
<span className="ml-auto rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums">
{approvalCount}
</span>
)}
</Link> </Link>
)} )}
</div> </div>
@@ -453,8 +435,6 @@ export function AppSidebar() {
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 } = useAuth()
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
const { serialNumbersEnabled } = useTrackingSettings()
// While /auth/me hasn't resolved yet, show nothing rather than briefly // 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
@@ -468,16 +448,8 @@ export function AppSidebar() {
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side. // anything server-side.
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry). const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
// someone has most likely just approved or submitted something.
const [approvalCount, setApprovalCount] = useState(0)
useEffect(() => {
if (loading) return
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
}, [loading, pathname])
const visibleItems = loading const visibleItems = loading
? [] ? []
: navItems : navItems
@@ -492,7 +464,7 @@ export function AppSidebar() {
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) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)), ),
})) }))
// Close mobile menu on route change // Close mobile menu on route change
@@ -517,7 +489,6 @@ export function AppSidebar() {
onCollapse={() => setCollapsed((v) => !v)} onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname} pathname={pathname}
isMobile={false} isMobile={false}
approvalCount={approvalCount}
/> />
</div> </div>
@@ -554,7 +525,6 @@ export function AppSidebar() {
onClose={() => setMobileOpen(false)} onClose={() => setMobileOpen(false)}
pathname={pathname} pathname={pathname}
isMobile={true} isMobile={true}
approvalCount={approvalCount}
/> />
</div> </div>
</> </>
@@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record<string, string> = {
vendors: "Vendors", vendors: "Vendors",
warehouse: "Warehouses", warehouse: "Warehouses",
receiving: "Receiving", receiving: "Receiving",
grn: "Goods Receipt Notes", grn: "Goods Received Notes",
stock: "Stock", stock: "Stock",
enquiry: "Enquiry", enquiry: "Enquiry",
ledger: "Ledger", ledger: "Ledger",
@@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record<string, string> = {
"purchase-invoices": "Purchase Invoices", "purchase-invoices": "Purchase Invoices",
"purchase-returns": "Purchase Returns", "purchase-returns": "Purchase Returns",
production: "Production", production: "Production",
hrm: "HRM",
templates: "Templates", templates: "Templates",
runs: "Runs", runs: "Runs",
invoices: "Sales", invoices: "Sales",
"day-end": "Approved Invoices",
} }
function labelFor(segment: string): string { function labelFor(segment: string): string {
@@ -51,6 +53,17 @@ function labelFor(segment: string): string {
return segment.charAt(0).toUpperCase() + segment.slice(1) return segment.charAt(0).toUpperCase() + segment.slice(1)
} }
/**
* Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the
* same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.)
*/
const GROUP_LANDING: Record<string, string> = {
"/dashboard/production": "/dashboard/production/runs",
"/dashboard/receiving": "/dashboard/receiving/grn",
"/dashboard/sales/bundles": "/dashboard/sales/bundles/templates",
"/dashboard/help/tickets": "/dashboard/help",
}
const linkClass = const linkClass =
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" "truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
@@ -61,10 +74,10 @@ export function Breadcrumbs() {
// Nothing to show above the dashboard root itself. // Nothing to show above the dashboard root itself.
if (segments.length <= 1) return null if (segments.length <= 1) return null
const crumbs = segments.map((segment, i) => ({ const crumbs = segments.map((segment, i) => {
href: "/" + segments.slice(0, i + 1).join("/"), const path = "/" + segments.slice(0, i + 1).join("/")
label: labelFor(segment), return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) }
})) })
return ( return (
<Breadcrumb className="mb-4 print:hidden"> <Breadcrumb className="mb-4 print:hidden">
@@ -72,7 +85,7 @@ export function Breadcrumbs() {
{crumbs.map((crumb, i) => { {crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1 const isLast = i === crumbs.length - 1
return ( return (
<Fragment key={crumb.href}> <Fragment key={crumb.key}>
<BreadcrumbItem> <BreadcrumbItem>
{isLast ? ( {isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage> <BreadcrumbPage>{crumb.label}</BreadcrumbPage>
@@ -108,10 +108,12 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes" if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note" if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note" if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note"
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname] if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
@@ -336,7 +338,8 @@ export function Header() {
<Building2 className="size-3.5" /> <Building2 className="size-3.5" />
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> {/* Open below the pill (not over it), left edges lined up; at least 14rem wide, or the pill's width if that is wider. */}
<SelectContent alignItemWithTrigger={false} side="bottom" align="start" sideOffset={6} className="w-auto min-w-[max(var(--anchor-width),14rem)] p-1">
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem> <SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
{branches.map((b) => ( {branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}> <SelectItem key={b.branchId} value={String(b.branchId)}>
@@ -425,10 +425,10 @@ export function AddTitleDialog({
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={saving}> <Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Adding…" : "Add title"} {saving ? "Adding…" : "Add title"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle> <DialogTitle className="pr-8 leading-snug">{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "} {isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
Pay the full balance or change the amount to pay part of it now.{" "} Pay the full balance or change the amount to pay part of it now.{" "}
@@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
</div> </div>
{accountsError && ( {accountsError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> <div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span> <span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}> <Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
Retry Retry
@@ -220,11 +223,13 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} /> <PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup> </FieldGroup>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}> <Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={submitting}> <Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"} {submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle> <DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} {isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span> Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
@@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} /> <PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup> </FieldGroup>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}> <Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={submitting}> <Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"} {submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
import { Brand } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
/**
* Creates a brand from a popup — used by the Brand tab and inline from New Item. Same
* validation and endpoint as before; the caller receives the created row.
*/
export function AddBrandDialog({
open,
onOpenChange,
onCreated,
description = "The new brand is selected for this item and offered on other items too.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (brand: Brand) => void
description?: string
}) {
const [name, setName] = useState("")
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function close(next: boolean) {
if (submitting) return
if (!next) {
setName("")
setError(null)
}
onOpenChange(next)
}
async function handleSave() {
const invalid = validateBrandName(name).name
if (invalid) {
setError(invalid)
toast.error("Could not create brand", invalid)
return
}
setSubmitting(true)
try {
const { data: brand } = await brandsApi.create({ name: name.trim() })
toast.success("Brand created", brand.name)
onCreated(brand)
setName("")
setError(null)
onOpenChange(false)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add brand</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="new-brand-name" className="text-base" required>
Name
</Label>
<Input
id="new-brand-name"
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}}
placeholder="e.g. Bosch"
aria-invalid={!!error}
className="h-12 text-base"
/>
<FieldError errors={[error ? { message: error } : undefined]} />
</div>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
{submitting ? "Saving…" : "Add Brand"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
/**
* Creates a UOM without leaving the current form (e.g. New Item). Same validation and
* endpoint as the UOM tab; the caller receives the created row to add and select it.
*/
export function AddUomDialog({
open,
onOpenChange,
onCreated,
description = "The new UOM is selected for this item and offered on other items too.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (uom: Uom) => void
description?: string
}) {
const [name, setName] = useState("")
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function close(next: boolean) {
if (submitting) return
if (!next) {
setName("")
setError(null)
}
onOpenChange(next)
}
async function handleSave() {
const invalid = validateUomName(name).name
if (invalid) {
setError(invalid)
toast.error("Could not create UOM", invalid)
return
}
setSubmitting(true)
try {
const uom = await uomsApi.create({ name: name.trim() })
toast.success("UOM created", uom.name)
onCreated(uom)
setName("")
setError(null)
onOpenChange(false)
} catch (err) {
setError(fieldErrors(err)?.name ?? errorMessage(err))
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add unit of measure</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="new-uom-name" className="text-base" required>
Name
</Label>
<Input
id="new-uom-name"
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}}
placeholder="e.g. Box-12"
aria-invalid={!!error}
className="h-12 text-base"
/>
<FieldError errors={[error ? { message: error } : undefined]} />
</div>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
{submitting ? "Saving…" : "Add UOM"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -8,6 +8,7 @@ import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data" import { validateBrandName } from "@/lib/validations/master-data"
import { Brand } from "@/types/master-data" import { Brand } from "@/types/master-data"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -25,9 +26,7 @@ export function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null) const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("") const [addOpen, setAddOpen] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null) const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
@@ -41,34 +40,14 @@ export function BrandPanel() {
brandsApi brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items)) .then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err))) .catch((err) => {
setError(errorMessage(err))
toast.error("Could not load brands", errorMessage(err))
})
} }
useEffect(load, []) useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create brand", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) { async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active" const next = brand.status === "Active" ? "Inactive" : "Active"
@@ -119,25 +98,18 @@ export function BrandPanel() {
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"> <div className="flex justify-end">
<Input <Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
value={name}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" /> <Plus className="size-5" />
Add Brand Add Brand
</Button> </Button>
</div> </div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>} <AddBrandDialog
open={addOpen}
onOpenChange={setAddOpen}
onCreated={load}
description="Brands are offered on every item."
/>
{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>
@@ -4,13 +4,12 @@ import { useEffect, useState } from "react"
import { Plus, Ruler } from "lucide-react" import { Plus, Ruler } from "lucide-react"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data" import { Uom } from "@/types/master-data"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -22,16 +21,17 @@ export function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null) const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("") const [addOpen, setAddOpen] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null) const [togglingId, setTogglingId] = useState<number | null>(null)
function load() { function load() {
uomsApi uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items)) .then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err))) .catch((err) => {
setError(errorMessage(err))
toast.error("Could not load UOMs", errorMessage(err))
})
} }
useEffect(load, []) useEffect(load, [])
@@ -50,51 +50,20 @@ export function UomPanel() {
} }
} }
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create UOM", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return ( 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"> <div className="flex justify-end">
<Input <Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
value={name}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" /> <Plus className="size-5" />
Add UOM Add UOM
</Button> </Button>
</div> </div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>} <AddUomDialog
open={addOpen}
onOpenChange={setAddOpen}
onCreated={load}
description="Units of measure are offered on every item."
/>
{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>
@@ -0,0 +1,38 @@
"use client"
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
/**
* Serial / warranty numbers shown as From – To ranges instead of one row per unit. A single
* run reads inline ("10 serial numbers: SN-001 – SN-010"); several runs expand behind a toggle.
*/
export function SerialRanges({ label, values }: { label: string; values: string[] }) {
const ranges = toSerialRanges(values)
if (ranges.length === 0) return null
const total = ranges.reduce((n, r) => n + r.count, 0)
const heading = `${total} ${label}${total === 1 ? "" : "s"}`
if (ranges.length <= 2) {
return (
<p className="break-all">
{heading}: <span className="font-mono font-medium text-foreground">{formatSerialRanges(ranges)}</span>
</p>
)
}
return (
<details>
<summary className="cursor-pointer select-none">
{heading} in {ranges.length} ranges: <span className="font-mono text-foreground">{formatSerialRanges(ranges)}</span>
</summary>
<ul className="mt-1 flex max-h-48 flex-col gap-0.5 overflow-y-auto font-mono">
{ranges.map((r) => (
<li key={r.from} className="flex justify-between gap-4">
<span className="break-all text-foreground">{r.count === 1 ? r.from : `${r.from} – ${r.to}`}</span>
<span className="shrink-0 tabular-nums">×{r.count}</span>
</li>
))}
</ul>
</details>
)
}
@@ -1,14 +1,41 @@
"use client" "use client"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js" import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Line } from "react-chartjs-2" import { Line } from "react-chartjs-2"
import { useIsMobile } from "@/hooks/use-mobile"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { DayEndReportDay } from "@/types/sales" import { DayEndReportDay } from "@/types/sales"
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend) ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */
const MOBILE_POINTS = 5
/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the
* legend box is made taller after it measures itself — the extra height sits below its items. */
const LEGEND_GAP_PX = 16
const legendGap: Plugin<"line"> = {
id: "legendGap",
beforeInit(chart) {
const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined
if (!legend) return
const fit = legend.fit.bind(legend)
legend.fit = function () {
fit()
this.height += LEGEND_GAP_PX
}
},
}
/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */
const LEGEND_TEXT_GAP = String.fromCharCode(0x2002)
/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */
const compactAmount = (v: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v)
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped). // Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
const SERIES = { const SERIES = {
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" }, light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
@@ -17,9 +44,12 @@ const SERIES = {
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared /** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */ * index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) { export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const isMobile = useIsMobile()
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays
const trimmed = days.length < allDays.length
const labels = days.map((d) => formatReportDate(d.date)) const labels = days.map((d) => formatReportDate(d.date))
const line = (label: string, data: number[], color: string, width: number) => ({ const line = (label: string, data: number[], color: string, width: number) => ({
@@ -27,16 +57,25 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
data, data,
borderColor: color, borderColor: color,
backgroundColor: color, backgroundColor: color,
borderWidth: width, borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width,
pointRadius: days.length > 45 ? 0 : 3, pointRadius: days.length > 45 ? 0 : 3,
pointHoverRadius: 5, pointHoverRadius: 5,
tension: 0.25, tension: 0.25,
fill: false, fill: false,
}) })
const tickFont = { size: isMobile ? 10 : 12 }
return ( return (
<div className="h-72 w-full"> <div className="flex w-full flex-col gap-1">
{trimmed && (
<p className="text-xs text-muted-foreground">
Showing the last {days.length} days — the table below lists every day.
</p>
)}
<div className="h-64 w-full sm:h-72">
<Line <Line
plugins={[legendGap]}
data={{ data={{
labels, labels,
datasets: [ datasets: [
@@ -51,7 +90,20 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: "index", intersect: false }, interaction: { mode: "index", intersect: false },
plugins: { plugins: {
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } }, // Legend on top (start-aligned on desktop, centred on phones) with small labels on phones.
legend: {
position: "top",
align: isMobile ? "center" : "start",
labels: {
color: c.text,
usePointStyle: true,
boxWidth: isMobile ? 6 : 8,
font: { size: isMobile ? 11 : 12 },
// Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it.
generateLabels: (chart) =>
ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })),
},
},
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx) => { label: (ctx) => {
@@ -66,11 +118,23 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
}, },
}, },
scales: { scales: {
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }, x: {
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } }, grid: { display: false },
ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont },
},
y: {
grid: { color: c.grid },
ticks: {
color: c.text,
font: tickFont,
maxTicksLimit: isMobile ? 5 : 8,
callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))),
},
},
}, },
}} }}
/> />
</div> </div>
</div>
) )
} }
@@ -147,9 +147,9 @@ function SerialPicker({
className="h-7 px-2 text-xs" className="h-7 px-2 text-xs"
disabled={toAdd.length === 0} disabled={toAdd.length === 0}
onClick={selectAll} onClick={selectAll}
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined} title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined}
> >
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"} Select all
</Button> </Button>
</div> </div>
<div className="max-h-60 overflow-y-auto rounded-md border"> <div className="max-h-60 overflow-y-auto rounded-md border">
@@ -63,7 +63,10 @@ interface PaymentLinesEditorProps {
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) { export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
const paid = paymentLinesTotal(lines) const paid = paymentLinesTotal(lines)
const remaining = round2(balance - paid) const remaining = round2(balance - paid)
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]" // Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up
// it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them.
const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]"
const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden"
function updateLine(key: number, patch: Partial<PaymentLine>) { function updateLine(key: number, patch: Partial<PaymentLine>) {
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l))) onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
@@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className={`grid ${cols} gap-2 text-sm font-medium`}> <div className={`hidden gap-2 text-sm font-medium sm:grid ${cols}`}>
<span>Payment type</span> <span>Payment type</span>
{methodOptions && <span>Method</span>} {methodOptions && <span>Method</span>}
<span>Paid amount</span> <span>Paid amount</span>
@@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</div> </div>
{lines.map((line, idx) => ( {lines.map((line, idx) => (
<div key={line.key} className={`grid ${cols} items-start gap-2`}> <div
<div> key={line.key}
className={`grid grid-cols-2 items-start gap-2 rounded-lg border p-3 sm:rounded-none sm:border-0 sm:p-0 ${cols}`}
>
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span className={mobileLabel}>Payment type {lines.length > 1 ? idx + 1 : ""}</span>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}> <Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
<SelectTrigger <SelectTrigger
className="w-full text-base" className="h-11 w-full text-base sm:h-9"
aria-label={`Payment type, line ${idx + 1}`} aria-label={`Payment type, line ${idx + 1}`}
aria-invalid={!!errors[`account-${line.key}`]} aria-invalid={!!errors[`account-${line.key}`]}
> >
@@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} /> <FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
</div> </div>
{methodOptions && ( {methodOptions && (
<div> <div className="flex flex-col gap-1">
<span className={mobileLabel}>Method</span>
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}> <Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}> <SelectTrigger className="h-11 w-full text-base sm:h-9" aria-label={`Payment method, line ${idx + 1}`}>
<SelectValue placeholder="Method" /> <SelectValue placeholder="Method" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</Select> </Select>
</div> </div>
)} )}
<div> <div className={`flex flex-col gap-1 sm:col-span-1 ${methodOptions ? "" : "col-span-2"}`}>
<span className={mobileLabel}>Paid amount</span>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
min="0.01" min="0.01"
inputMode="decimal"
value={line.amount} value={line.amount}
onChange={(e) => updateLine(line.key, { amount: e.target.value })} onChange={(e) => updateLine(line.key, { amount: e.target.value })}
aria-label={`Paid amount, line ${idx + 1}`} aria-label={`Paid amount, line ${idx + 1}`}
aria-invalid={!!errors[`amount-${line.key}`]} aria-invalid={!!errors[`amount-${line.key}`]}
className="text-right tabular-nums" className="h-11 text-right text-base tabular-nums sm:h-9"
/> />
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} /> <FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
</div> </div>
<div className="flex gap-1"> <div className="col-span-2 flex items-center justify-between gap-1 sm:col-span-1 sm:justify-start">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -148,12 +158,13 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
onClick={() => onChange(lines.filter((l) => l.key !== line.key))} onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
disabled={lines.length === 1} disabled={lines.length === 1}
aria-label={`Remove line ${idx + 1}`} aria-label={`Remove line ${idx + 1}`}
className="text-destructive hover:bg-destructive/10 disabled:text-muted-foreground"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
{/* Add sits on the last line itself, so a new split appears right below it. */} {/* Add sits on the last line itself, so a new split appears right below it. */}
{idx === lines.length - 1 && ( {idx === lines.length - 1 && (
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap"> <Button type="button" variant="outline" size="sm" onClick={addLine} className="h-10 whitespace-nowrap sm:mt-1 sm:h-8">
<Plus className="size-4" /> <Plus className="size-4" />
Add line Add line
</Button> </Button>
@@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
))} ))}
{/* Total is what's owed (fixed); Paid is what the lines add up to. */} {/* Total is what's owed (fixed); Paid is what the lines add up to. */}
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm"> <div className="grid w-full grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm sm:ml-auto sm:max-w-64">
<span className="text-muted-foreground">Total</span> <span className="text-muted-foreground">Total</span>
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span> <span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
<span className="text-muted-foreground">Paid amount</span> <span className="text-muted-foreground">Paid amount</span>
@@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle className="pr-8 leading-snug">
Pay {ordered.length} invoices — {customerName} Pay {ordered.length} invoices — {customerName}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{draftCount > 0 && ( {draftCount > 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "} {draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
@@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
</p> </p>
)} )}
<div className="max-h-48 overflow-y-auto rounded-lg border"> <div className="max-h-48 shrink-0 overflow-auto rounded-lg border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground"> <thead className="bg-muted/50 text-muted-foreground">
<tr> <tr>
<th className="px-3 py-2 text-left font-medium">Invoice</th> <th className="px-3 py-2 text-left font-medium">Invoice</th>
<th className="px-3 py-2 text-left font-medium">Date</th> <th className="hidden px-3 py-2 text-left font-medium sm:table-cell">Date</th>
<th className="px-3 py-2 text-right font-medium">Balance</th> <th className="px-3 py-2 text-right font-medium">Balance</th>
<th className="px-3 py-2 text-right font-medium">Applied</th> <th className="px-3 py-2 text-right font-medium">Applied</th>
</tr> </tr>
@@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
<tbody> <tbody>
{allocation.map(({ inv, applied }) => ( {allocation.map(({ inv, applied }) => (
<tr key={inv.salesInvoiceId} className="border-t"> <tr key={inv.salesInvoiceId} className="border-t">
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td> <td className="px-3 py-2 font-medium whitespace-nowrap">{inv.invoiceNo}</td>
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td> <td className="hidden px-3 py-2 sm:table-cell">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td> <td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td> <td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
</tr> </tr>
@@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
/> />
</FieldGroup> </FieldGroup>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}> <Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={submitting}> <Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"} {submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle> <DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} {isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span> Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
@@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
/> />
</FieldGroup> </FieldGroup>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}> <Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={submitting}> <Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"} {submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// Mirrors Branch.DefaultCode (backend) — the deployment's seeded branch, which
// BranchService blocks deactivating or deleting (every branch-scoped entity needs
// at least one branch to exist).
const DEFAULT_BRANCH_CODE = "BR-000"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and // 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.
const MOBILE_PAGE_SIZE_STEP = 10 const MOBILE_PAGE_SIZE_STEP = 10
@@ -329,12 +333,16 @@ export function BranchMasterPage() {
size="icon-sm" size="icon-sm"
className={cn( className={cn(
item.status === "Active" item.status === "Active"
? "text-destructive hover:bg-destructive/10" ? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10" : "text-success hover:bg-success/10"
)} )}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} title={
disabled={actionPendingId === item.branchId} item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)} onClick={() => toggleStatus(item)}
> >
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} {item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -345,10 +353,10 @@ export function BranchMasterPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`} aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`} title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId} disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/> />
} }
> >
@@ -433,12 +441,16 @@ export function BranchMasterPage() {
size="icon-sm" size="icon-sm"
className={cn( className={cn(
item.status === "Active" item.status === "Active"
? "text-destructive hover:bg-destructive/10" ? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10" : "text-success hover:bg-success/10"
)} )}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} title={
disabled={actionPendingId === item.branchId} item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)} onClick={() => toggleStatus(item)}
> >
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} {item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -449,10 +461,10 @@ export function BranchMasterPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`} aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`} title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId} disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/> />
} }
> >
@@ -88,9 +88,10 @@ function AlertDialogContent({
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay data-no-row-nav />
<AlertDialogPrimitive.Popup <AlertDialogPrimitive.Popup
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
data-no-row-nav
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm", "fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
"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", "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",
+2 -1
View File
@@ -49,9 +49,10 @@ function DialogContent({
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay data-no-row-nav />
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
data-no-row-nav
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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 className
@@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
? 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} />
)) ))
: 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-24 rounded-2xl" />)}
</div> </div>
) )
} }
+37 -14
View File
@@ -46,63 +46,86 @@ export interface StatCardProps {
label: string label: string
value: string | number value: string | number
icon?: LucideIcon icon?: LucideIcon
/** Small muted line under the value, e.g. "All time" or "3 with balance due". */
hint?: 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[] trend?: number[]
className?: string className?: string
/** Extra classes for the value, e.g. a success/destructive colour. */
valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string
} }
/**
* The app's KPI tile: a tinted icon square on the left, then label / bold value / optional
* hint stacked beside it. Every list page's stat row uses this so they all read the same.
*/
export function StatCard({ export function StatCard({
label, label,
value, value,
icon: Icon, icon: Icon,
hint,
delta, delta,
deltaLabel, deltaLabel,
invert = false, invert = false,
trend, trend,
className, className,
valueClassName,
iconClassName,
}: 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
const isGood = hasDelta && (invert ? !isPositive : isPositive) const isGood = hasDelta && (invert ? !isPositive : isPositive)
return ( return (
// A size container: the card reads its *own* width, so it lays out the same whether a page puts
// it in a 2-up phone grid or a wide desktop row.
<div <div
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", "@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className className
)} )}
> >
<div className="flex items-start justify-between gap-2"> {/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
<p className="text-sm font-medium text-muted-foreground">{label}</p> value keeps the full width instead of being cut off. */}
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
{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" /> className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
iconClassName
)}
>
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
</div> </div>
)} )}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm text-muted-foreground">{label}</p>
<div className="flex items-end justify-between gap-2"> <div className="flex items-end justify-between gap-2">
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p> <p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
{formatValue(value)}
</p>
{trend && trend.length > 1 && <Sparkline points={trend} />} {trend && trend.length > 1 && <Sparkline points={trend} />}
</div> </div>
{hasDelta && ( {hasDelta ? (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
<span <span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
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>
) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
)} )}
</div> </div>
</div>
</div>
) )
} }
@@ -0,0 +1,36 @@
import { useState } from "react"
import { PaginationMeta } from "@/types/common"
/**
* Pages an already-loaded list in the browser, producing the same `PaginationMeta` shape the
* server-paged lists use so it plugs straight into `PaginationFooter`.
*
* `resetKey` (e.g. the selected date) sends the list back to page 1 when it changes. It's a key
* rather than the array itself because callers often pass `data ?? []`, a new array every render.
*/
export function useClientPagination<T>(rows: T[], resetKey: unknown, initialPageSize = 10) {
const [page, setPage] = useState(1)
const [pageSize, setPageSizeState] = useState(initialPageSize)
const [prevKey, setPrevKey] = useState(resetKey)
// Reset during render (React's "adjust state when a prop changes" pattern) — no effect needed.
if (!Object.is(prevKey, resetKey)) {
setPrevKey(resetKey)
setPage(1)
}
const totalItems = rows.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
// Clamped, so shrinking the list (or a bigger page size) never leaves an empty page showing.
const current = Math.min(page, totalPages)
const pageRows = rows.slice((current - 1) * pageSize, current * pageSize)
const pagination: PaginationMeta = { page: current, pageSize, totalItems, totalPages }
function setPageSize(size: number) {
setPageSizeState(size)
setPage(1)
}
return { pageRows, pagination, setPage, setPageSize }
}
+5
View File
@@ -35,4 +35,9 @@ export const authApi = {
refresh(): Promise<AuthSession> { refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} }) return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
}, },
/** Consumes a 15-minute reset-link token (from the emailed link's `?token=`) and sets a new password. */
resetPasswordWithToken(request: { resetToken: string; newPassword: string; confirmPassword: string }): Promise<void> {
return apiRequest<void>("/auth/recovery/reset-password-token", { method: "POST", body: request })
},
} }
+16 -1
View File
@@ -245,7 +245,22 @@ export const bankAccountsApi = {
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request }) return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
}, },
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today /** Rename only — the default cash account allows this too, just not delete/deactivate. */
updateCash(cashAccountId: number, accountName: string): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}`, { method: "PUT", body: { accountName } })
},
/** Throws (surfaced via errorMessage) if deactivating the default cash account. */
setCashStatus(cashAccountId: number, isActive: boolean): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}/status`, { method: "PATCH", body: { isActive } })
},
/** Throws if this is the default cash account. */
deleteCash(cashAccountId: number): Promise<void> {
return glRequest<void>(`/cash-accounts/${cashAccountId}`, { method: "DELETE" })
},
// No get()/update() for Bank: GL exposes no GET/PUT by id for bank_account today
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit"). // (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
} }
+6
View File
@@ -9,6 +9,7 @@ import {
SalesInvoiceStatus, SalesInvoiceStatus,
SalesInvoicePayment, SalesInvoicePayment,
SalesInvoicePostingCheck, SalesInvoicePostingCheck,
SalesInvoiceListTotals,
SalesInvoiceSummary, SalesInvoiceSummary,
SalesListFilterParams, SalesListFilterParams,
SalesReportDefinition, SalesReportDefinition,
@@ -56,6 +57,11 @@ export const salesApi = {
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`) return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
}, },
/** Count/amount totals for the same filters as `listInvoices`, across every match (not one page). */
invoiceTotals(params: Omit<ListSalesInvoicesParams, "page" | "pageSize"> = {}): Promise<SalesInvoiceListTotals> {
return apiRequest<SalesInvoiceListTotals>(`/sales-invoices/totals${buildQuery(params)}`)
},
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */ /** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> { getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`) return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
+12
View File
@@ -8,6 +8,8 @@ import {
ManagedUser, ManagedUser,
PurgeAuthHexIdentityRequest, PurgeAuthHexIdentityRequest,
PurgeAuthHexIdentityResult, PurgeAuthHexIdentityResult,
SendResetLinkResponse,
UpdateManagedUserRequest,
UpdateUserRoleRequest, UpdateUserRoleRequest,
UpdateUserStatusRequest, UpdateUserStatusRequest,
UserTypeOption, UserTypeOption,
@@ -38,6 +40,16 @@ export const usersApi = {
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request }) return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
}, },
/** Edits DisplayName/Email/MobileNumber on both backends (local shadow row + AuthHex identity). */
update(userId: number, request: UpdateManagedUserRequest): Promise<ManagedUser> {
return apiRequest<ManagedUser>(`/users/${userId}`, { method: "PUT", body: request })
},
/** Emails the user a 15-minute password-reset link via AuthHex's forgot-password flow. */
sendResetLink(userId: number): Promise<SendResetLinkResponse> {
return apiRequest<SendResetLinkResponse>(`/users/${userId}/send-reset-link`, { method: "POST" })
},
/** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */ /** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> { updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request }) return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
+59
View File
@@ -0,0 +1,59 @@
/**
* Collapses serial / warranty numbers into consecutive runs for display:
* SN-001, SN-002 … SN-050, SN-077 → "SN-001 – SN-050" (50) and "SN-077" (1).
*
* Uses the same "prefix + trailing number" reading as the GRN form's range builder, so a run
* entered there as From/adding comes back out as exactly one range here.
*/
export type SerialRange = { from: string; to: string; count: number }
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
const m = s.match(/^(.*?)(\d+)$/)
if (!m) return null
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
}
/** Natural order: by prefix, then by the trailing number (so SN-9 sorts before SN-10). */
function compareSerials(a: string, b: string): number {
const pa = parseTrailingNumber(a)
const pb = parseTrailingNumber(b)
if (pa && pb && pa.prefix === pb.prefix) return pa.num - pb.num
return a.localeCompare(b, undefined, { numeric: true })
}
export function toSerialRanges(values: string[]): SerialRange[] {
const sorted = [...new Set(values.map((v) => v.trim()).filter(Boolean))].sort(compareSerials)
const ranges: SerialRange[] = []
let run: { start: string; prefix: string; width: number; last: number; lastText: string; count: number } | null = null
for (const value of sorted) {
const parsed = parseTrailingNumber(value)
// Consecutive = same prefix, next number, written with the run's zero-padding.
const continues =
run !== null &&
parsed !== null &&
parsed.prefix === run.prefix &&
parsed.num === run.last + 1 &&
value === `${run.prefix}${String(parsed.num).padStart(run.width, "0")}`
if (continues && run && parsed) {
run.last = parsed.num
run.lastText = value
run.count += 1
continue
}
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
run = parsed
? { start: value, prefix: parsed.prefix, width: parsed.width, last: parsed.num, lastText: value, count: 1 }
: { start: value, prefix: `\u0000${value}`, width: 0, last: Number.NaN, lastText: value, count: 1 }
}
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
return ranges
}
/** One-line summary, e.g. "SN-001 – SN-050" or "SN-001 – SN-050, SN-077 +2 more". */
export function formatSerialRanges(ranges: SerialRange[], max = 2): string {
const label = (r: SerialRange) => (r.count === 1 ? r.from : `${r.from} – ${r.to}`)
const shown = ranges.slice(0, max).map(label).join(", ")
return ranges.length > max ? `${shown} +${ranges.length - max} more` : shown
}
@@ -0,0 +1,56 @@
/**
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
*
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits.
*/
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/
/** Digit count allowed after the prefix's own digits, per format. */
const LOCAL_DIGITS = 10 // 0 + 9
const INTL_DIGITS = 11 // 94 + 9
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
/**
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting
* digits once the number is full, so the limit is enforced while typing rather than on save.
*/
export function limitPhoneInput(raw: string): string {
let out = ""
let digits = 0
for (const ch of raw) {
if (ch === "+") {
if (out.length === 0) out += ch // "+" only as the very first character
continue
}
if (ch === " " || ch === "-") {
out += ch
continue
}
if (!/\d/.test(ch)) continue
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS
if (digits >= max) continue
out += ch
digits += 1
}
return out
}
/** Error message for an invalid phone, or null when valid. Blank is valid unless `required`. */
export function phoneError(value: string, { required = false }: { required?: boolean } = {}): string | null {
const phone = value.trim()
if (!phone) return required ? "Enter a phone number" : null
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
const digits = phone.replace(/\D/g, "")
if (phone.startsWith("+")) {
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)`
} else {
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}`
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
}
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
return null
}
@@ -294,6 +294,9 @@ export interface CashAndBankAccountDto {
glAccountCode: string glAccountCode: string
currencyCode: string currencyCode: string
createdAt: string createdAt: string
/** Cash only — null for Bank rows, which carry no such protection yet. */
isActive: boolean | null
isDefault: boolean | null
} }
/** /**
+10
View File
@@ -41,6 +41,16 @@ export interface SalesInvoiceTotals {
balanceAmount: number balanceAmount: number
} }
/** Totals over every invoice matching the list filters (GET /sales-invoices/totals), not one page. */
export interface SalesInvoiceListTotals {
count: number
netTotal: number
paidTotal: number
balanceTotal: number
unpaidCount: number
overdueCount: number
}
export interface SalesInvoiceSummary { export interface SalesInvoiceSummary {
salesInvoiceId: number salesInvoiceId: number
invoiceNo: string invoiceNo: string
+11
View File
@@ -44,6 +44,17 @@ export interface UpdateUserRoleRequest {
roleId: number roleId: number
} }
/** Edits both backends — the local shadow row, and (when linked) the AuthHex identity itself. */
export interface UpdateManagedUserRequest {
displayName: string
email: string
mobileNumber?: string | null
}
export interface SendResetLinkResponse {
expiresAt: string | null
}
export interface UpdateUserStatusRequest { export interface UpdateUserStatusRequest {
status: EntityStatus status: EntityStatus
} }