Dev #114

Merged
ashan_rusiru merged 5 commits from Dev into production 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}")]
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)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
@@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
public async Task<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}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[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)
=> 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")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken 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"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
@@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser
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
{
/// <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,
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(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
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; }
}
/// <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>
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>
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
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
=> CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser",
new { email, mobileNumber, nic, userName = username },
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 ------------------------------------------------
@@ -38,6 +38,15 @@ public interface IAuthHexClient
/// </summary>
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)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest 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)
?? throw new NotFoundException($"Branch {branchId} was not found.");
if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deactivated.",
409);
branch.Status = status;
branch.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
@@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
if (branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deleted.",
409);
_branches.Remove(branch);
try
{
@@ -8,6 +8,8 @@ namespace ERPCore.Services.Interfaces;
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);
/// <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<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, 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> 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>
/// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the
/// credential itself stays live upstream; this only revokes the account's
/// standing inside ERPCore.
/// BranchAccessService and the rest of ERPCore gate on — and, when the user has a
/// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus
/// function (deactivating additionally invalidates their live AuthHex sessions), so a
/// user shown Inactive here can no longer log in at all, not just inside ERPCore.
/// </summary>
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>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
@@ -25,6 +25,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments;
private readonly IRepository<PriceAdjustmentLine> _lines;
private readonly IRepository<Item> _items;
private readonly IRepository<ItemPriceHistory> _history;
private readonly IRepository<User> _users;
@@ -36,11 +37,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
private readonly IUnitOfWork _uow;
public PriceAdjustmentService(
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history,
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals,
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IRepository<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items,
IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> config,
IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_lines = lines;
_items = items;
_history = history;
_users = users;
@@ -92,10 +95,14 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
var ids = request.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
var pendingItemIds = await _adjustments.Query().AsNoTracking()
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo }))
.Where(x => ids.Contains(x.ItemId))
// Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a
// plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that
// Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this
// predates — 2026-10-16 MySQL-port regression).
var pendingItemIds = await _lines.Query().AsNoTracking()
.Where(l => ids.Contains(l.ItemId)
&& (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval))
.Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo })
.ToListAsync(ct);
var lines = new List<PriceAdjustmentLine>();
+24 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_users = users;
_currentUser = currentUser;
_authHex = authHex;
_branchConfig = branchConfig;
@@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
if (status == EntityStatus.Inactive)
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
role.Status = status;
role.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
@@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
try
{
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
@@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService
await _uow.SaveChangesAsync(ct);
}
/// <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)
{
_ = 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)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
var config = await LoadAsync(ct);
// "Allow cash customer" can be on with no customer linked — the flag was set outside
// UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer
// because there's no id to offer, so link (or create) CUST-CASH here instead.
if (config.AllowCashCustomer && config.CashCustomerId is null)
{
var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct);
if (tracked is not null && tracked.CashCustomerId is null)
{
tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// A concurrent save got there first — whatever it stored is re-read below.
}
}
config = await LoadAsync(ct);
}
return new ETagged<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(
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)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<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 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 = query.Q.Trim();
var term = search.Trim();
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
@@ -72,11 +106,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// paid=true: nothing left to pay; paid=false: a balance is still due.
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
return q;
}
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService
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)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
// could still log in directly against AuthHex.
if (user.AuthUserId is Guid authUserId)
await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct);
user.Status = status;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<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)
{
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 PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
+2 -1
View File
@@ -9,7 +9,8 @@
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
"BaseUrl": "http://localhost:5011",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/"
+2 -1
View File
@@ -3,7 +3,8 @@
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:6703",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
},
"GeneralLedgerService": {
"BaseUrl": "http://localhost:6704/api/v1/",
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
import { ArrowLeft, CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
@@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both"
@@ -27,23 +31,25 @@ export default function BankAccountsPage() {
const [search, setSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(() => {
let cancelled = false
const [editTarget, setEditTarget] = useState<CashAndBankAccountDto | null>(null)
const [editName, setEditName] = useState("")
const [editError, setEditError] = useState<string | null>(null)
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()])
.then(([banks, gl]) => {
if (cancelled) return
setAccounts(banks)
setGlAccounts(gl.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [accountType])
.catch((err) => setError(errorMessage(err)))
}
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(load, [accountType])
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
@@ -64,6 +70,57 @@ export default function BankAccountsPage() {
})
}, [accounts, search, glAccountsById])
function openEdit(a: CashAndBankAccountDto) {
setEditTarget(a)
setEditName(a.accountName)
setEditError(null)
}
async function handleRename() {
if (!editTarget) return
if (!editName.trim()) {
setEditError("Account name is required")
return
}
setEditSubmitting(true)
try {
await bankAccountsApi.updateCash(editTarget.accountId, editName.trim())
toast.success("Cash account renamed")
setEditTarget(null)
load()
} catch (err) {
setEditError(errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
async function toggleStatus(a: CashAndBankAccountDto) {
setStatusPendingId(a.accountId)
try {
await bankAccountsApi.setCashStatus(a.accountId, !a.isActive)
toast.success(a.isActive ? "Cash account deactivated" : "Cash account activated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setStatusPendingId(null)
}
}
async function handleDelete(a: CashAndBankAccountDto) {
setDeletingId(a.accountId)
try {
await bankAccountsApi.deleteCash(a.accountId)
toast.success("Cash account deleted")
load()
} catch (err) {
toast.error("Could not delete cash account", errorMessage(err))
} finally {
setDeletingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -167,19 +224,61 @@ export default function BankAccountsPage() {
<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">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
{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>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for bank accounts.
</TooltipContent>
</Tooltip>
)}
</TableCell>
</TableRow>
)
@@ -187,6 +286,34 @@ export default function BankAccountsPage() {
</TableBody>
</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>
)
}
@@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** What the edit dialog is editing — a category (no GL code) or a title. */
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
export default function ExpenseSetupPage() {
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const [editing, setEditing] = useState<Editing>(null)
const [editing, setEditing] = useState<ExpenseTitle | null>(null)
function load() {
expenseSetupApi
@@ -45,10 +42,10 @@ export default function ExpenseSetupPage() {
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1>
<p className="text-base text-muted-foreground">
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Expense Setup</h1>
<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.
</p>
</div>
@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() {
{categories && (
<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) => (
<button
key={c.expenseCategoryId}
onClick={() => setSelectedId(c.expenseCategoryId)}
aria-current={c.expenseCategoryId === selectedId ? "true" : undefined}
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.status === "Inactive" && "opacity-60"
)}
>
<span className="font-medium">{c.name}</span>
<span className="text-xs tabular-nums">{c.titles.length}</span>
<span className="min-w-0 truncate font-medium">{c.name}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums">{c.titles.length}</span>
</button>
))}
</nav>
@@ -85,16 +85,10 @@ export default function ExpenseSetupPage() {
{selected.isSystem && <SystemBadge />}
{selected.status === "Inactive" && <InactiveBadge />}
</div>
<div className="flex gap-2">
<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" />
Add title
</Button>
</div>
<Button size="sm" className="h-10 w-full sm:h-8 sm:w-auto" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-4" />
Add title
</Button>
</div>
{selected.code === "SALARIES" && (
@@ -106,7 +100,36 @@ export default function ExpenseSetupPage() {
{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>
) : (
<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">
<TableHeader>
<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">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
<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" />
Edit
</Button>
@@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
</TableBody>
</Table>
</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 [glCode, setGlCode] = useState("")
const [active, setActive] = useState(true)
@@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
const [saving, setSaving] = useState(false)
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
const key = editing?.expenseTitleId ?? null
const [resetFor, setResetFor] = useState<number | null>(null)
if (key !== resetFor) {
setResetFor(key)
if (editing) {
setName(editing.row.name)
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "")
setActive(editing.row.status === "Active")
setName(editing.name)
setGlCode(editing.glExpenseAccountCode)
setActive(editing.status === "Active")
setError(null)
}
}
if (!editing) return null
const isSystem = editing.row.isSystem
const isSystem = editing.isSystem
async function submit() {
if (!editing) return
if (!name.trim()) return setError("Enter a name")
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code")
if (!glCode.trim()) return setError("Enter the GL expense account code")
const status: EntityStatus = active ? "Active" : "Inactive"
setSaving(true)
try {
if (editing.kind === "category") {
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
} else {
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
}
await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
toast.success("Saved", name.trim())
onSaved()
onClose()
@@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle>
<DialogTitle>Edit title</DialogTitle>
</DialogHeader>
<FieldGroup>
<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)} />
{isSystem && <FieldDescription>Built-in names can&apos;t be changed.</FieldDescription>}
</Field>
{editing.kind === "title" && (
<Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<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>
</Field>
)}
<Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<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>
</Field>
<Field orientation="horizontal">
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
<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>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button variant="outline" className="h-11 sm:h-9" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
@@ -236,11 +236,13 @@ export default function CustomersPage() {
</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">
<DialogTitle>New customer</DialogTitle>
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
</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>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
@@ -263,10 +265,10 @@ export default function CustomersPage() {
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<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>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
@@ -281,7 +283,7 @@ export default function CustomersPage() {
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<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" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<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)} />
@@ -318,11 +320,12 @@ export default function CustomersPage() {
</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={() => setOpen(false)} disabled={submitting}>
</div>
<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
</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"}
</Button>
</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"
const TREND_DAYS = 14
const MOBILE_TREND_DAYS = 5
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5
@@ -112,6 +113,16 @@ export default function DashboardPage() {
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile()
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
const shownTrend = useMemo(
() => ({
labels: movementTrend.labels.slice(-trendDays),
inData: movementTrend.inData.slice(-trendDays),
outData: movementTrend.outData.slice(-trendDays),
}),
[movementTrend, trendDays]
)
const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
@@ -130,26 +141,28 @@ export default function DashboardPage() {
</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 ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
</Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
</Link>
</>
) : (
@@ -199,23 +212,23 @@ export default function DashboardPage() {
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<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>
</div>
{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">
<LineChart
labels={movementTrend.labels}
labels={shownTrend.labels}
datasets={[
{ label: "In", data: movementTrend.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: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</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" />
@@ -230,7 +230,7 @@ function NewPurchaseReturnContent() {
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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>
items={grnOptions}
value={grnId}
@@ -63,6 +63,8 @@ import {
ITEM_IMAGE_MAX_COUNT,
itemImageProblem,
} from "@/components/products/ItemImagesPanel"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
function skuSegment(text: string, maxLen: number): string {
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
@@ -118,6 +120,8 @@ export default function NewItemPage() {
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
/** Defaults to the first UOM once loaded; null only means none exist yet. */
const [baseUomId, setBaseUomId] = useState<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.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -216,7 +220,11 @@ export default function NewItemPage() {
setBaseUomId(uoms.items[0]?.uomId ?? null)
setWarehouses(wh.items)
})
.catch((err) => setLoadError(errorMessage(err)))
.catch((err) => {
const message = errorMessage(err)
setLoadError(message)
toast.error("Could not load the form", message)
})
// Optional extra: a failure here just means only the default periods are offered.
warrantyPeriodsApi
.list({ status: "Active" })
@@ -350,6 +358,7 @@ export default function NewItemPage() {
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
if (parsed.error !== undefined) {
setCustomPeriodError(parsed.error)
toast.error("Invalid warranty period", parsed.error)
return
}
setItemOnlyWarrantyMonths(parsed.months)
@@ -375,24 +384,30 @@ export default function NewItemPage() {
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
*/
/** Inline message under the custom-variant box, mirrored as a toast. */
function failCustom(message: string) {
setCustomError(message)
toast.error("Could not add custom variant", message)
}
function createCustomVariant() {
const name = customName.trim() || (customIsSize ? "Size" : "")
const invalid = validateItemTypeName(name).name
if (invalid) {
setCustomError(invalid)
failCustom(invalid)
return
}
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
setCustomError(`"${name}" already exists — tick it above instead.`)
failCustom(`"${name}" already exists — tick it above instead.`)
return
}
if (customIsSize && !measurementUnit) {
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
failCustom("Pick a Measurement unit at the top first — size values are entered in it.")
return
}
// Only one dimension may supply the content size, or a variant would have two.
if (customIsSize && measurableCheckedCount > 0) {
setCustomError("Only one size variant can be used at a time — untick the other one first.")
failCustom("Only one size variant can be used at a time — untick the other one first.")
return
}
const local: ItemType = {
@@ -413,6 +428,10 @@ export default function NewItemPage() {
* duplicate pair never reaches state, so nothing downstream has to defend against one.
*/
function addValue(itemTypeId: number) {
const failValue = (message: string) => {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: message }))
toast.error("Could not add value", message)
}
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) {
@@ -435,11 +454,11 @@ export default function NewItemPage() {
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
failValue("Enter a size greater than 0")
return
}
if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
failValue("Select a unit")
return
}
@@ -448,7 +467,7 @@ export default function NewItemPage() {
const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
failValue("That size is already in the list")
return
}
@@ -708,13 +727,44 @@ export default function NewItemPage() {
const loading = !categories || !brands || !itemTypes || !config
/** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */
function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) {
const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}`
const invalid = kind === "flat" && !!priceErrors[variantKey]
return (
<Input
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-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
<div className="flex flex-col gap-5 sm:gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">New Item</h1>
<p className="text-sm text-muted-foreground sm:text-base">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
{loadError && (
@@ -726,17 +776,19 @@ export default function NewItemPage() {
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM —{" "}
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
create one first
</Link>
<button type="button" onClick={() => setAddUomOpen(true)} className="font-semibold underline">
add one now
</button>
.
</div>
)}
{!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="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base" required>Product name</Label>
<Input
value={productName}
@@ -802,108 +854,72 @@ export default function NewItemPage() {
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</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>
</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">
<Label className="text-base" required>Base UOM</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
value={baseUomId}
onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</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 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 className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* The unit size variants are measured in. Picked once here; every size value added
below (custom size variant or a measurement Item Variant) starts in this unit. */}
@@ -1026,11 +1042,88 @@ export default function NewItemPage() {
</p>
</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) && (
<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 && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
@@ -1054,8 +1147,8 @@ export default function NewItemPage() {
</RadioGroup>
{priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex flex-col gap-2 sm:min-w-56 sm:flex-1">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
@@ -1071,6 +1164,7 @@ export default function NewItemPage() {
<Button
type="button"
variant="outline"
className="h-11 w-full sm:w-auto"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
@@ -1096,7 +1190,7 @@ export default function NewItemPage() {
)}
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
@@ -1140,7 +1234,7 @@ export default function NewItemPage() {
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" />
</SelectTrigger>
<SelectContent>
@@ -1253,7 +1347,7 @@ export default function NewItemPage() {
)}
{config?.itemImagesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
@@ -1275,7 +1369,7 @@ export default function NewItemPage() {
type="button"
onClick={() => removePendingImage(p.url)}
aria-label={`Remove ${p.file.name}`}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
>
<X className="size-3.5" />
</button>
@@ -1309,7 +1403,7 @@ export default function NewItemPage() {
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
<p className="text-sm text-muted-foreground">
@@ -1329,7 +1423,7 @@ export default function NewItemPage() {
)}
</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) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two.
@@ -1339,7 +1433,7 @@ export default function NewItemPage() {
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
"flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
@@ -1362,7 +1456,7 @@ export default function NewItemPage() {
})}
{config?.customVariantsEnabled && !customOpen && (
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
<Button type="button" variant="outline" className="h-11" onClick={() => setCustomOpen(true)}>
<Plus className="size-4" />
Custom variant
</Button>
@@ -1414,11 +1508,11 @@ export default function NewItemPage() {
/>
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
</label>
<div className="flex gap-2 sm:ml-auto">
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
<div className="grid grid-cols-2 gap-2 sm:ml-auto sm:flex">
<Button type="button" variant="ghost" className="h-11 sm:h-9" onClick={closeCustomVariant}>
Cancel
</Button>
<Button type="button" onClick={createCustomVariant}>
<Button type="button" className="h-11 sm:h-9" onClick={createCustomVariant}>
Add
</Button>
</div>
@@ -1490,9 +1584,15 @@ export default function NewItemPage() {
</SelectContent>
</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" />
Add
<span className="hidden sm:inline">Add</span>
</Button>
</div>
) : (
@@ -1509,9 +1609,15 @@ export default function NewItemPage() {
placeholder={t.name}
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" />
Add {t.name}
<span className="hidden sm:inline">Add {t.name}</span>
</Button>
</div>
)}
@@ -1525,7 +1631,7 @@ export default function NewItemPage() {
<button
type="button"
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}`}
>
<X className="size-3" />
@@ -1544,111 +1650,117 @@ export default function NewItemPage() {
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
{activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
<>
{/* 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" && (
<>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
<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>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
<TableHead className="h-11 w-8 px-1" />
</TableRow>
</TableHeader>
<TableBody>
{variants.map((variant) => (
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value.label}
</TableCell>
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
{activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5">
{contentLabelFor(variant)}
</TableCell>
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<>
<TableCell className="px-3 py-2.5">
<Input
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]} />
</TableCell>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</TableCell>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
<TableHead className="h-11 w-8 px-1" />
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{variants.map((variant) => (
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value.label}
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5">
{contentLabelFor(variant)}
</TableCell>
{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">
{priceInput(variant.key, "flat", "w-28")}
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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="flex justify-end gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
<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
</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"}
</Button>
</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>
)
}
@@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory,
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
@@ -187,7 +188,9 @@ export default function ItemsPage() {
if (!append) setSelected(new Set())
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
if (cancelled) return
setError(errorMessage(err))
toast.error("Could not load items", errorMessage(err))
})
.finally(() => {
if (!cancelled) setLoadingMore(false)
@@ -344,19 +347,30 @@ export default function ItemsPage() {
<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>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and
emphasised on desktop; the secondary actions share a row beneath it on mobile. */}
<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" />
Category/Brand/UOM
<span className="truncate">
<span className="sm:hidden">Masters</span>
<span className="hidden sm:inline">Category/Brand/UOM</span>
</span>
</Link>
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-5" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
</Link>
</div>
</div>
@@ -475,7 +489,7 @@ export default function ItemsPage() {
<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>
{!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" />
New Item
</Link>
@@ -23,14 +23,15 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { SerialRanges } from "@/components/receiving/SerialRanges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
/** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */
function LineTrackingInfo({ line }: { line: GrnLine }) {
const serials = line.serials ?? []
if (!line.batchNo && serials.length === 0) return null
if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null
return (
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
{line.batchNo && (
@@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) {
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
</p>
)}
{serials.length > 0 && (
<details>
<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>
)}
<SerialRanges label="serial number" values={serials.map((s) => s.serialNo)} />
<SerialRanges label="warranty number" values={line.warrantyNumbers.map((w) => w.warrantyNo)} />
</div>
)
}
@@ -112,7 +103,13 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
grnsApi
.get(grnId)
.then(setGrn)
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRN", errorMessage(err))
})
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
.then(([it, uo]) => {
setItems(it.items)
@@ -258,11 +255,6 @@ export default function GrnDetailPage() {
<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>}
</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} />
</div>
<HoldStatusBadge status={line.holdStatus} />
@@ -378,14 +370,6 @@ export default function GrnDetailPage() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</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} />
</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)
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
})
.catch((err) => setLoadError(errorMessage(err)))
.catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the GRN form", errorMessage(err))
})
}, [])
useEffect(() => {
@@ -318,6 +321,27 @@ export default function NewGrnPage() {
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = warehouses ?? []
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
function failHeader(message: string) {
setHeaderError(message)
toast.error("Check the GRN details", message)
}
function failScan(message: string) {
setScanError(message)
toast.error("Scan failed", message)
}
function failSubmit(message: string) {
setSubmitError(message)
toast.error("Could not create GRN", message)
}
function failWarranty(lineKey: string, message: string) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message }))
toast.error("Could not fill warranty numbers", message)
}
function switchMode(next: Mode) {
setMode(next)
setPoId(null)
@@ -342,7 +366,7 @@ export default function NewGrnPage() {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
if (openLines.length === 0) {
setHeaderError("This purchase order has no open (unreceived) lines.")
failHeader("This purchase order has no open (unreceived) lines.")
setLines([])
return
}
@@ -369,7 +393,7 @@ export default function NewGrnPage() {
})
)
} catch (err) {
setHeaderError(errorMessage(err))
failHeader(errorMessage(err))
} finally {
setPoLoading(false)
}
@@ -403,9 +427,9 @@ export default function NewGrnPage() {
addItemLine(result.itemId, scannedBatchNo)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
failScan(`No item or lot found for "${code}"`)
} else {
setScanError(errorMessage(err))
failScan(errorMessage(err))
}
} finally {
setScanning(false)
@@ -526,7 +550,7 @@ export default function NewGrnPage() {
function replaceWarrantyNumbers(lineKey: string, units: number) {
const range = warrantyRangeInputs[lineKey]
if (!range?.from.trim() || !range?.adding.trim()) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
failWarranty(lineKey, "Enter both a from number and an adding number")
return
}
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
@@ -536,14 +560,11 @@ export default function NewGrnPage() {
const to = applyAddingNumber(from, range.adding)
const seq = buildWarrantyRange(from, to)
if (!seq) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
failWarranty(lineKey, "The computed number must share From's prefix and not come before it")
return
}
if (seq.length > units) {
setWarrantyAddErrors((prev) => ({
...prev,
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
}))
failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`)
return
}
setWarrantyAddErrors((prev) => {
@@ -636,23 +657,23 @@ export default function NewGrnPage() {
poId: mode === "po" ? poId : null,
})
if (!header.success) {
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.")
failHeader(header.error.issues[0]?.message ?? "Check the header fields.")
return
}
if (mode === "direct" && !vendorId) {
setHeaderError("Select a vendor for a direct receipt.")
failHeader("Select a vendor for a direct receipt.")
return
}
if (mode === "po" && !poId) {
setHeaderError("Select a purchase order.")
failHeader("Select a purchase order.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this GRN.")
failHeader("Select a branch for this GRN.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
failSubmit("Add at least one line.")
return
}
@@ -680,6 +701,12 @@ export default function NewGrnPage() {
)
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
setSubmitError("Fix the highlighted lines before submitting.")
// The toast names the first problem so it's useful even when that line is scrolled out of view.
const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))]
toast.error(
"Fix the highlighted lines before submitting",
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
)
return
}
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -97,7 +98,10 @@ export default function GrnListPage() {
setGrns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRNs", errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
}
@@ -114,7 +118,7 @@ export default function GrnListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
<h1 className="text-2xl font-bold text-foreground">Goods Received Notes</h1>
<p className="text-base text-muted-foreground">
Receive goods against a purchase order, or record a direct receipt.
</p>
@@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
@@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean })
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<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. */
function ProfitStatement({ day }: { day: DayEndReportDay }) {
const gross = profitOrLoss(day.grossProfit)
@@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) {
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
*/
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
const depositPage = useClientPagination(deposits, date)
const remaining = day.closingCashInHand ?? 0
return (
<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="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p>
<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>
<StatCard
label="Remaining cash in hand"
value={formatAmount(remaining)}
icon={PiggyBank}
hint={remaining > 0 ? "Not yet deposited" : "All cash banked"}
className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined}
valueClassName={remaining > 0 ? "text-warning" : undefined}
/>
</div>
<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
@@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
{deposits.length === 0 ? (
<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>
<TableHeader>
<TableRow>
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
</TableRow>
</TableHeader>
<TableBody>
{deposits.map((d) => (
{depositPage.pageRows.map((d) => (
<TableRow key={d.bankDepositId}>
<TableCell>
<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>
</TableFooter>
</Table>
</div>
{depositPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={depositPage.pagination} onPageChange={depositPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={depositPage.setPageSize} />
)}
</>
)}
</div>
)
@@ -272,6 +310,10 @@ export default function DayEndReportPage() {
const sales = day?.data?.sales ?? []
const expenses = day?.data?.expenses ?? []
const deposits = day?.data?.deposits ?? []
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
const salesPage = useClientPagination(sales, date)
const expensePage = useClientPagination(expenses, date)
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return (
<div className="flex flex-col gap-6">
@@ -305,7 +347,7 @@ export default function DayEndReportPage() {
{!s && !day?.error && (
<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>
)}
@@ -325,27 +367,15 @@ export default function DayEndReportPage() {
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Income</p>
{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>
<StatCard
label="Income"
value={formatSignedAmount(s.income)}
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"}
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
/>
</div>
<p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
@@ -361,7 +391,48 @@ export default function DayEndReportPage() {
{sales.length === 0 ? (
<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>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
@@ -378,7 +449,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<TableBody>
{sales.map((row) => (
{salesPage.pageRows.map((row) => (
<TableRow key={`${row.docType}-${row.docId}`}>
<TableCell className="tabular-nums">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
@@ -415,6 +486,10 @@ export default function DayEndReportPage() {
</TableFooter>
</Table>
</div>
{salesPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={salesPage.pagination} onPageChange={salesPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={salesPage.setPageSize} />
)}
</>
)}
</div>
@@ -423,6 +498,29 @@ export default function DayEndReportPage() {
{expenses.length === 0 ? (
<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>
<TableHeader>
<TableRow>
@@ -435,7 +533,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<TableBody>
{expenses.map((e) => (
{expensePage.pageRows.map((e) => (
<TableRow key={e.expenseId}>
<TableCell>
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
@@ -456,6 +554,11 @@ export default function DayEndReportPage() {
</TableRow>
</TableFooter>
</Table>
</div>
{expensePage.pagination.totalItems > 10 && (
<PaginationFooter pagination={expensePage.pagination} onPageChange={expensePage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={expensePage.setPageSize} />
)}
</>
)}
</div>
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {
<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>
</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">
<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 className="flex flex-col gap-1.5">
<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>
<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" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button>
@@ -493,7 +596,7 @@ export default function DayEndReportPage() {
{!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="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
@@ -510,9 +613,64 @@ export default function DayEndReportPage() {
/>
</div>
<DayEndIncomeChart days={range.data.days} />
{/* 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} />
</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>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
@@ -531,7 +689,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<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">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
@@ -568,6 +726,9 @@ export default function DayEndReportPage() {
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
@@ -2,17 +2,17 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { salesDayEndApi } from "@/lib/api/sales-day-end"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
@@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
type SalesDateRange = "All" | "Today" | "7d" | "30d"
@@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "Paid", label: "Fully paid" },
]
export default function SalesDayEndPage() {
export default function ApprovedInvoicesPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [businessDate, setBusinessDate] = useState(todayIso())
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null)
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
// Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
// cashier's sales) and a date range.
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
const effectiveSalesCashier = salesCashier ?? cashierUserId
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
const [salesCashier, setSalesCashier] = useState<number | "All">("All")
const effectiveSalesCashier = salesCashier
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
const [salesSearchInput, setSalesSearchInput] = useState("")
@@ -135,30 +120,10 @@ export default function SalesDayEndPage() {
useEffect(() => {
usersApi
.list({ pageSize: 200 })
.then((res) => {
setCashiers(res.items)
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
})
.then((res) => setCashiers(res.items))
.catch(() => {})
}, [])
function loadPreview() {
if (!cashierUserId) return
setReport(null)
setPreviewError(null)
setPreviewLoading(true)
salesDayEndApi
.preview(cashierUserId, businessDate)
.then(setPreview)
.catch((err) => setPreviewError(errorMessage(err)))
.finally(() => setPreviewLoading(false))
}
useEffect(() => {
loadPreview()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate])
// Debounced search; a new term starts again from page 1.
useEffect(() => {
const timeout = setTimeout(() => {
@@ -170,7 +135,6 @@ export default function SalesDayEndPage() {
}, [salesSearchInput])
useEffect(() => {
if (effectiveSalesCashier === null) return
salesApi
.listInvoices({
page: salesPage,
@@ -186,6 +150,41 @@ export default function SalesDayEndPage() {
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
// Stat cards: the same filters as the table, totalled over every match (not one page). Keyed
// like the table so a change of dropdown — or a payment — refreshes them.
const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|")
const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null)
const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined
// If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed
// from the loaded page and labelled as such — rather than disappearing.
const pageTotals: SalesInvoiceListTotals | null = sales
? {
count: salesPagination?.totalItems ?? sales.length,
netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0),
paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0),
balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0),
unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length,
overdueCount: sales.filter(isOverdue).length,
}
: null
const pageOnly = totals === null
const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals
useEffect(() => {
salesApi
.invoiceTotals({
status: "Posted",
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
q: salesQuery || undefined,
})
.then((data) => setTotalsResult({ key: totalsKey, data }))
// Cards are a summary — a failure blanks them (the table reports its own errors).
.catch(() => setTotalsResult({ key: totalsKey, data: null }))
}, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery])
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
function reloadSales() {
setSelected(new Map())
@@ -198,12 +197,6 @@ export default function SalesDayEndPage() {
setSelected(new Map())
}
function changeCashier(userId: number | null) {
setCashierUserId(userId)
setSalesCashier(null)
resetSalesView()
}
function changeSalesCashier(value: number | "All") {
setSalesCashier(value)
resetSalesView()
@@ -224,23 +217,6 @@ export default function SalesDayEndPage() {
resetSalesView()
}
async function handleClose() {
if (!cashierUserId) return
setClosing(true)
try {
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
setReport(result)
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
loadPreview()
} catch (err) {
toast.error("Could not close the day", errorMessage(err))
} finally {
setClosing(false)
}
}
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
@@ -262,220 +238,28 @@ export default function SalesDayEndPage() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<div className="flex flex-col gap-4 sm:gap-6">
<div className="flex items-start gap-3 sm:items-center">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }), "shrink-0")}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
<p className="text-base text-muted-foreground">
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Approved Invoices</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Approved sales invoices — view them and take payments, one at a time or several of one customer together.
</p>
</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">
<div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label>
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
<SelectContent>
{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>
)}
<InvoiceTotalsCards
totals={cardTotals}
pageOnly={pageOnly}
scope={[
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "",
paymentFilters.find((p) => p.value === salesPayment)?.label ?? "",
].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")}
/>
<SalesTable
rows={sales}
@@ -634,24 +418,28 @@ function SalesTable({
const visible = rows ?? []
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 (
<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>
<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>
</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>
value={cashierFilter}
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
>
<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" />
</SelectTrigger>
<SelectContent>
@@ -661,12 +449,13 @@ function SalesTable({
))}
</SelectContent>
</Select>
</div>
<Select<SalesDateRange>
value={dateRange}
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
onValueChange={(v) => onDateRangeChange(v ?? "All")}
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" />
<SelectValue />
</SelectTrigger>
@@ -681,7 +470,7 @@ function SalesTable({
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
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 />
</SelectTrigger>
<SelectContent>
@@ -690,12 +479,6 @@ function SalesTable({
))}
</SelectContent>
</Select>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="h-10 text-sm"
/>
</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 && (
<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">
<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 tabular-nums">{selectedBalance.toFixed(2)}</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
<div className="grid grid-cols-2 gap-2 sm:flex">
<Button type="button" variant="outline" size="sm" className="h-10 sm:h-8" onClick={onClearSelection}>
Clear
</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" />
Pay selected
</Button>
@@ -729,48 +513,66 @@ function SalesTable({
{!error && rows !== null && visible.length > 0 && (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so
nothing is squeezed. Tapping the details opens the invoice. */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:hidden">
{visible.map((row) => (
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
{renderSelectBox(row)}
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate font-medium">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
<div
key={row.salesInvoiceId}
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="block truncate font-semibold">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</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} />
</span>
</button>
{renderActions(row)}
</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)}
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
{/* 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 lg:block">
<Table className="text-sm xl:text-base">
<TableHeader>
<TableRow>
<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">Customer</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-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-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -790,16 +592,16 @@ function SalesTable({
{renderSelectBox(row)}
</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 && (
<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")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</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
className={cn(
"px-3 py-3.5 text-right tabular-nums",
@@ -833,52 +635,61 @@ function SalesTable({
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>
)
}
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
if (invoices.invoiceCount === 0) return null
return (
<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>
)
}
/** Shared card shell — tighter padding on phones, roomier from `sm` up. */
const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5"
/**
* Top-of-page totals for whatever the dropdowns/search currently select — across every matching
* invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available).
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/
function InvoiceTotalsCards({
totals, scope, pageOnly,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
if (totals === undefined) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
)
}
if (totals === null) return null
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}>
{isCount ? value : value.toFixed(2)}
</span>
<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>
)
}
@@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map"
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
@@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<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>
{(line.serials ?? []).length > 0 && (
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")}
// From – To runs, not one entry per unit (full list on hover).
<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>
)}
</td>
@@ -154,7 +154,8 @@ export default function SalesInvoicesPage() {
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
// Includes the cash customer so its (walk-in) invoices can be filtered too.
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
@@ -24,8 +24,8 @@ const sections = [
icon: PackageX,
},
{
title: "Day End",
description: "Close a cashier's day and post it to the ledger.",
title: "Approved Invoices",
description: "Approved sales invoices — view them and take payments.",
href: "/dashboard/sales/day-end",
icon: Lock,
},
@@ -3,15 +3,14 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { Pencil, Plus, Power, Search, ShieldCheck, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Role } from "@/types/rbac"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
@@ -72,14 +71,15 @@ export default function RolesPage() {
return () => clearTimeout(t)
}, [search])
async function handleDelete(role: Role) {
async function toggleStatus(role: Role) {
const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active"
setActionPendingId(role.roleId)
try {
await rolesApi.remove(role.roleId)
toast.success("Role deleted", `${role.code} has been removed.`)
await rolesApi.updateStatus(role.roleId, next)
toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`)
load()
} catch (err) {
toast.error("Could not delete role", errorMessage(err))
toast.error("Could not update role status", errorMessage(err))
} finally {
setActionPendingId(null)
}
@@ -236,29 +236,26 @@ export default function RolesPage() {
>
<Pencil className="size-4" />
</Link>
<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 ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<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>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
r.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "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)}
>
<Power className="size-4" />
</Button>
</div>
</div>
</div>
@@ -347,29 +344,26 @@ export default function RolesPage() {
<Pencil className="size-4" />
</Link>
<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 ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<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>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
r.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "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)}
>
<Power className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react"
import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-react"
import { auditLogsApi } from "@/lib/api/audit-logs"
import { usersApi } from "@/lib/api/users"
@@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit"
import { ManagedUser } from "@/types/users"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
@@ -36,6 +39,15 @@ export default function UserDetailPage() {
const [statusPending, setStatusPending] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [displayName, setDisplayName] = useState("")
const [email, setEmail] = useState("")
const [mobileNumber, setMobileNumber] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [sendingResetLink, setSendingResetLink] = useState(false)
useEffect(() => {
if (!Number.isFinite(userId)) return
setLoadError(null)
@@ -85,7 +97,10 @@ export default function UserDetailPage() {
try {
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
setUser(updated)
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated")
toast.success(
nextStatus === "Active" ? "User activated" : "User deactivated",
nextStatus === "Inactive" ? "They can no longer sign in." : undefined
)
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
@@ -93,6 +108,54 @@ export default function UserDetailPage() {
}
}
function openEdit() {
if (!user) return
setDisplayName(user.displayName)
setEmail(user.email ?? "")
setMobileNumber("")
setEditErrors({})
setEditOpen(true)
}
async function handleSave() {
if (!user) return
const nextErrors: Record<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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -112,16 +175,26 @@ export default function UserDetailPage() {
<p className="text-base text-muted-foreground">{user.displayName}</p>
</div>
<div className="flex items-center gap-2">
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
{user.status === "Active" ? "Active" : "Inactive"}
</span>
<Switch
checked={user.status === "Active"}
onCheckedChange={handleToggleStatus}
disabled={statusPending}
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
/>
<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">
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
{user.status === "Active" ? "Active" : "Inactive"}
</span>
<Switch
checked={user.status === "Active"}
onCheckedChange={handleToggleStatus}
disabled={statusPending}
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
/>
</div>
</div>
</div>
@@ -264,6 +337,39 @@ export default function UserDetailPage() {
)}
</div>
</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>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { KeyRound, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -45,6 +45,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
@@ -105,6 +106,19 @@ export default function UsersPage() {
}
}
async function handleSendResetLink(u: ManagedUser) {
setResetLinkPendingId(u.userId)
try {
const result = await usersApi.sendResetLink(u.userId)
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
toast.success("Reset link sent", `Emailed to ${u.email}. Expires at ${expiry}.`)
} catch (err) {
toast.error("Could not send reset link", errorMessage(err))
} finally {
setResetLinkPendingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<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">
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
<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
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -250,6 +256,17 @@ export default function UsersPage() {
>
<Pencil className="size-4" />
</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
type="button"
variant="ghost"
@@ -324,14 +341,6 @@ export default function UsersPage() {
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<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
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -340,6 +349,17 @@ export default function UsersPage() {
>
<Pencil className="size-4" />
</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
type="button"
variant="ghost"
@@ -33,7 +33,7 @@ const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
/** Friendly names for the document-type prefixes a serial movement references. */
const DOC_LABEL: Record<string, string> = {
GRN: "Goods receipt",
GRN: "Goods received",
SI: "Sales invoice",
SSL: "Sales slip",
BND: "Bundle sale",
+7 -19
View File
@@ -14,6 +14,7 @@ import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn"
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 { Input } from "@/components/ui/input"
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]
/** 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() {
const params = useParams<{ id: string }>()
const vendorId = Number(params.id)
@@ -293,11 +279,13 @@ export default function VendorDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
{/* Fixed-share columns so the three cards stay compact on wide screens instead of each taking a third. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<StatCard icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatCard icon={PackageCheck} label="Goods received (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatCard
icon={Truck}
className="col-span-2 sm:col-span-1"
label="Last delivery"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
* so an empty/punctuation-only name still yields a usable base. */
@@ -153,13 +154,18 @@ export default function WarehousesPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(phone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create warehouse", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
closeCreate()
load()
@@ -187,13 +193,18 @@ export default function WarehousesPage() {
const nextErrors: Record<string, string> = {}
if (!editCode.trim()) nextErrors.code = "Code is required"
if (!editName.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(editPhone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not update warehouse", Object.values(nextErrors)[0])
return
}
setEditSubmitting(true)
try {
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId })
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId })
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
setEditTarget(null)
load()
@@ -262,9 +273,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<Field data-invalid={!!errors.phone}>
<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>
{warehouseBranchingEnabled && (
<Field data-invalid={!!errors.branchId}>
@@ -556,9 +578,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<Field data-invalid={!!editErrors.phone}>
<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>
{warehouseBranchingEnabled && (
<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,
ReceiptText,
Scale,
ScanLine,
ScrollText,
Settings,
ShieldCheck,
@@ -62,12 +61,8 @@ import {
type LucideIcon,
} from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
const SERIALS_HREF = "/dashboard/stock/serials"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
@@ -109,8 +104,6 @@ const navItems: {
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
],
},
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
{
title: "Procurement",
code: "procurement",
@@ -159,9 +152,6 @@ const navItems: {
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
@@ -250,7 +240,6 @@ function SidebarContent({
onClose,
pathname,
isMobile,
approvalCount,
}: {
items: typeof navItems
collapsed: boolean
@@ -258,8 +247,6 @@ function SidebarContent({
onClose?: () => void
pathname: string
isMobile: boolean
/** Requests the user can approve — the badge on Approvals. */
approvalCount: number
}) {
const iconOnly = !isMobile && collapsed
@@ -381,11 +368,6 @@ function SidebarContent({
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <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>
)}
</div>
@@ -453,8 +435,6 @@ export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { navCodes, loading } = useAuth()
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
const { serialNumbersEnabled } = useTrackingSettings()
// While /auth/me hasn't resolved yet, show nothing rather than briefly
// flashing the full menu to a restricted role. Once resolved, a nav item
@@ -468,16 +448,8 @@ export function AppSidebar() {
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry).
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
// someone has most likely just approved or submitted something.
const [approvalCount, setApprovalCount] = useState(0)
useEffect(() => {
if (loading) return
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
}, [loading, pathname])
const visibleItems = loading
? []
: navItems
@@ -492,7 +464,7 @@ export function AppSidebar() {
children: (bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)),
),
}))
// Close mobile menu on route change
@@ -517,7 +489,6 @@ export function AppSidebar() {
onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname}
isMobile={false}
approvalCount={approvalCount}
/>
</div>
@@ -554,7 +525,6 @@ export function AppSidebar() {
onClose={() => setMobileOpen(false)}
pathname={pathname}
isMobile={true}
approvalCount={approvalCount}
/>
</div>
</>
@@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record<string, string> = {
vendors: "Vendors",
warehouse: "Warehouses",
receiving: "Receiving",
grn: "Goods Receipt Notes",
grn: "Goods Received Notes",
stock: "Stock",
enquiry: "Enquiry",
ledger: "Ledger",
@@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record<string, string> = {
"purchase-invoices": "Purchase Invoices",
"purchase-returns": "Purchase Returns",
production: "Production",
hrm: "HRM",
templates: "Templates",
runs: "Runs",
invoices: "Sales",
"day-end": "Approved Invoices",
}
function labelFor(segment: string): string {
@@ -51,6 +53,17 @@ function labelFor(segment: string): string {
return segment.charAt(0).toUpperCase() + segment.slice(1)
}
/**
* Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the
* same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.)
*/
const GROUP_LANDING: Record<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 =
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
@@ -61,10 +74,10 @@ export function Breadcrumbs() {
// Nothing to show above the dashboard root itself.
if (segments.length <= 1) return null
const crumbs = segments.map((segment, i) => ({
href: "/" + segments.slice(0, i + 1).join("/"),
label: labelFor(segment),
}))
const crumbs = segments.map((segment, i) => {
const path = "/" + segments.slice(0, i + 1).join("/")
return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) }
})
return (
<Breadcrumb className="mb-4 print:hidden">
@@ -72,7 +85,7 @@ export function Breadcrumbs() {
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<Fragment key={crumb.href}>
<Fragment key={crumb.key}>
<BreadcrumbItem>
{isLast ? (
<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/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note"
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
@@ -336,7 +338,8 @@ export function Header() {
<Building2 className="size-3.5" />
<SelectValue />
</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>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>
@@ -425,10 +425,10 @@ export function AddTitleDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Adding…" : "Add title"}
</Button>
</DialogFooter>
@@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
return (
<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>
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
</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">
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
Pay the full balance or change the amount to pay part of it now.{" "}
@@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
</div>
{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>
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
Retry
@@ -220,11 +223,13 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</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"}
</Button>
</DialogFooter>
@@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
return (
<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>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</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">
{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>
@@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</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"}
</Button>
</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 { Brand } from "@/types/master-data"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -25,9 +26,7 @@ export function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
@@ -41,34 +40,14 @@ export function BrandPanel() {
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load brands", errorMessage(err))
})
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create brand", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
@@ -119,25 +98,18 @@ export function BrandPanel() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
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}>
<div className="flex justify-end">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
<Plus className="size-5" />
Add Brand
</Button>
</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 && (
<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 { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { errorMessage } from "@/lib/error-map"
import { Uom } from "@/types/master-data"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -22,16 +21,17 @@ export function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load UOMs", errorMessage(err))
})
}
useEffect(load, [])
@@ -50,51 +50,20 @@ export function UomPanel() {
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create UOM", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
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}>
<div className="flex justify-end">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
<Plus className="size-5" />
Add UOM
</Button>
</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 && (
<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"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js"
import { useTheme } from "next-themes"
import { Line } from "react-chartjs-2"
import { useIsMobile } from "@/hooks/use-mobile"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { DayEndReportDay } from "@/types/sales"
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */
const MOBILE_POINTS = 5
/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the
* legend box is made taller after it measures itself — the extra height sits below its items. */
const LEGEND_GAP_PX = 16
const legendGap: Plugin<"line"> = {
id: "legendGap",
beforeInit(chart) {
const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined
if (!legend) return
const fit = legend.fit.bind(legend)
legend.fit = function () {
fit()
this.height += LEGEND_GAP_PX
}
},
}
/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */
const LEGEND_TEXT_GAP = String.fromCharCode(0x2002)
/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */
const compactAmount = (v: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v)
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
const SERIES = {
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
@@ -17,9 +44,12 @@ const SERIES = {
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) {
const { resolvedTheme } = useTheme()
const isMobile = useIsMobile()
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays
const trimmed = days.length < allDays.length
const labels = days.map((d) => formatReportDate(d.date))
const line = (label: string, data: number[], color: string, width: number) => ({
@@ -27,50 +57,84 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
data,
borderColor: color,
backgroundColor: color,
borderWidth: width,
borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width,
pointRadius: days.length > 45 ? 0 : 3,
pointHoverRadius: 5,
tension: 0.25,
fill: false,
})
const tickFont = { size: isMobile ? 10 : 12 }
return (
<div className="h-72 w-full">
<Line
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
<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
plugins={[legendGap]}
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
// Legend on top (start-aligned on desktop, centred on phones) with small labels on phones.
legend: {
position: "top",
align: isMobile ? "center" : "start",
labels: {
color: c.text,
usePointStyle: true,
boxWidth: isMobile ? 6 : 8,
font: { size: isMobile ? 11 : 12 },
// Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it.
generateLabels: (chart) =>
ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })),
},
},
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
},
},
},
},
},
scales: {
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
},
}}
/>
scales: {
x: {
grid: { display: false },
ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont },
},
y: {
grid: { color: c.grid },
ticks: {
color: c.text,
font: tickFont,
maxTicksLimit: isMobile ? 5 : 8,
callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))),
},
},
},
}}
/>
</div>
</div>
)
}
@@ -147,9 +147,9 @@ function SerialPicker({
className="h-7 px-2 text-xs"
disabled={toAdd.length === 0}
onClick={selectAll}
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined}
>
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
Select all
</Button>
</div>
<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) {
const paid = paymentLinesTotal(lines)
const remaining = round2(balance - paid)
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
// Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up
// it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them.
const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]"
const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden"
function updateLine(key: number, patch: Partial<PaymentLine>) {
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
@@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
return (
<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>
{methodOptions && <span>Method</span>}
<span>Paid amount</span>
@@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</div>
{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 ?? "")}>
<SelectTrigger
className="w-full text-base"
className="h-11 w-full text-base sm:h-9"
aria-label={`Payment type, line ${idx + 1}`}
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]} />
</div>
{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 })}>
<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" />
</SelectTrigger>
<SelectContent>
@@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</Select>
</div>
)}
<div>
<div className={`flex flex-col gap-1 sm:col-span-1 ${methodOptions ? "" : "col-span-2"}`}>
<span className={mobileLabel}>Paid amount</span>
<Input
type="number"
step="0.01"
min="0.01"
inputMode="decimal"
value={line.amount}
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
aria-label={`Paid amount, line ${idx + 1}`}
aria-invalid={!!errors[`amount-${line.key}`]}
className="text-right tabular-nums"
className="h-11 text-right text-base tabular-nums sm:h-9"
/>
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
</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
type="button"
variant="ghost"
@@ -148,12 +158,13 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
disabled={lines.length === 1}
aria-label={`Remove line ${idx + 1}`}
className="text-destructive hover:bg-destructive/10 disabled:text-muted-foreground"
>
<Trash2 className="size-4" />
</Button>
{/* Add sits on the last line itself, so a new split appears right below it. */}
{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" />
Add line
</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. */}
<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-right font-semibold tabular-nums">{formatAmount(balance)}</span>
<span className="text-muted-foreground">Paid amount</span>
@@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
return (
<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>
<DialogTitle>
<DialogTitle className="pr-8 leading-snug">
Pay {ordered.length} invoices — {customerName}
</DialogTitle>
</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 && (
<p className="text-sm text-muted-foreground">
{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>
)}
<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">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<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">Applied</th>
</tr>
@@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
<tbody>
{allocation.map(({ inv, applied }) => (
<tr key={inv.salesInvoiceId} className="border-t">
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
<td className="px-3 py-2 font-medium whitespace-nowrap">{inv.invoiceNo}</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">{applied > 0 ? formatAmount(applied) : "—"}</td>
</tr>
@@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
/>
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</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"}
</Button>
</DialogFooter>
@@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
return (
<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>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</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">
{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>
@@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
/>
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</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"}
</Button>
</DialogFooter>
@@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// Mirrors Branch.DefaultCode (backend) — the deployment's seeded branch, which
// BranchService blocks deactivating or deleting (every branch-scoped entity needs
// at least one branch to exist).
const DEFAULT_BRANCH_CODE = "BR-000"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
@@ -329,12 +333,16 @@ export function BranchMasterPage() {
size="icon-sm"
className={cn(
item.status === "Active"
? "text-destructive hover:bg-destructive/10"
? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10"
)}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
disabled={actionPendingId === item.branchId}
title={
item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)}
>
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -345,10 +353,10 @@ export function BranchMasterPage() {
<Button
variant="ghost"
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}`}
title={`Delete ${item.name}`}
disabled={deletingId === item.branchId}
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/>
}
>
@@ -433,12 +441,16 @@ export function BranchMasterPage() {
size="icon-sm"
className={cn(
item.status === "Active"
? "text-destructive hover:bg-destructive/10"
? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10"
)}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
disabled={actionPendingId === item.branchId}
title={
item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)}
>
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -449,10 +461,10 @@ export function BranchMasterPage() {
<Button
variant="ghost"
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}`}
title={`Delete ${item.name}`}
disabled={deletingId === item.branchId}
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/>
}
>
@@ -88,9 +88,10 @@ function AlertDialogContent({
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogOverlay data-no-row-nav />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-no-row-nav
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",
"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 (
<DialogPortal>
<DialogOverlay />
<DialogOverlay data-no-row-nav />
<DialogPrimitive.Popup
data-slot="dialog-content"
data-no-row-nav
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",
className
@@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
? tiles.map((tile) => (
<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>
)
}
+47 -24
View File
@@ -46,63 +46,86 @@ export interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
/** Small muted line under the value, e.g. "All time" or "3 with balance due". */
hint?: string
delta?: number
deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean
trend?: number[]
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({
label,
value,
icon: Icon,
hint,
delta,
deltaLabel,
invert = false,
trend,
className,
valueClassName,
iconClassName,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
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
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
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
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 && (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-xs">
<span
<div
className={cn(
"font-semibold",
isGood ? "text-success" : "text-destructive"
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
iconClassName
)}
>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
</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">
<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} />}
</div>
{hasDelta ? (
<div className="flex items-center gap-1 text-xs">
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
)}
</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> {
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 })
},
// 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").
}
+6
View File
@@ -9,6 +9,7 @@ import {
SalesInvoiceStatus,
SalesInvoicePayment,
SalesInvoicePostingCheck,
SalesInvoiceListTotals,
SalesInvoiceSummary,
SalesListFilterParams,
SalesReportDefinition,
@@ -56,6 +57,11 @@ export const salesApi = {
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). */
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
+12
View File
@@ -8,6 +8,8 @@ import {
ManagedUser,
PurgeAuthHexIdentityRequest,
PurgeAuthHexIdentityResult,
SendResetLinkResponse,
UpdateManagedUserRequest,
UpdateUserRoleRequest,
UpdateUserStatusRequest,
UserTypeOption,
@@ -38,6 +40,16 @@ export const usersApi = {
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. */
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
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
currencyCode: 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
}
/** 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 {
salesInvoiceId: number
invoiceNo: string
+11
View File
@@ -44,6 +44,17 @@ export interface UpdateUserRoleRequest {
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 {
status: EntityStatus
}