Merge pull request 'Dev' (#114) from Dev into production
Reviewed-on: #114
This commit was merged in pull request #114.
This commit is contained in:
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
|
|||||||
[HttpPut("{**path}")]
|
[HttpPut("{**path}")]
|
||||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||||
|
|
||||||
|
[HttpPatch("{**path}")]
|
||||||
|
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
|
||||||
|
|
||||||
|
[HttpDelete("{**path}")]
|
||||||
|
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
|
||||||
|
|
||||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
|
||||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||||
return new ContentResult
|
return new ContentResult
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||||
|
|
||||||
|
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
|
||||||
|
[HttpGet("totals")]
|
||||||
|
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
|
||||||
|
[FromQuery] string? q,
|
||||||
|
[FromQuery] SalesInvoiceStatus? status,
|
||||||
|
[FromQuery] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
[FromQuery] int? branchId,
|
||||||
|
[FromQuery] DateOnly? from,
|
||||||
|
[FromQuery] DateOnly? to,
|
||||||
|
[FromQuery] int? createdBy,
|
||||||
|
[FromQuery] bool? paid,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
|
||||||
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
/// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
|
||||||
|
[HttpPut("{userId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.UpdateAsync(userId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||||
[HttpPatch("{userId:int}/status")]
|
[HttpPatch("{userId:int}/status")]
|
||||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||||
|
|
||||||
|
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
|
||||||
|
[HttpPost("{userId:int}/send-reset-link")]
|
||||||
|
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
|
||||||
|
=> Ok(await _users.SendResetLinkAsync(userId, ct));
|
||||||
|
|
||||||
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
||||||
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||||
[HttpGet("{userId:int}/login-devices")]
|
[HttpGet("{userId:int}/login-devices")]
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser
|
|||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
|
||||||
|
public sealed class AdminUpdateUserRequest
|
||||||
|
{
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? MobileNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class RegisterRequest
|
public sealed class RegisterRequest
|
||||||
{
|
{
|
||||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto(
|
|||||||
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||||
|
|
||||||
|
/// <summary>Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page.</summary>
|
||||||
|
public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount);
|
||||||
|
|
||||||
public sealed record SalesInvoiceSummaryDto(
|
public sealed record SalesInvoiceSummaryDto(
|
||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||||
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
||||||
|
|||||||
@@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest
|
|||||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edits an existing user's identity fields in both backends: the local shadow row
|
||||||
|
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
|
||||||
|
/// account itself via the admin-only AdminUpdateUser function. A user created before
|
||||||
|
/// AuthHex linking (AuthUserId null) only updates locally.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateManagedUserRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
|
||||||
|
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
|
||||||
|
public string? MobileNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
|
||||||
|
|
||||||
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
||||||
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
|
|||||||
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
|
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
|
||||||
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
|
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
|
||||||
|
|
||||||
|
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
|
||||||
|
/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits.</summary>
|
||||||
|
public static class PhoneRules
|
||||||
|
{
|
||||||
|
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$";
|
||||||
|
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567).";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class CreateWarehouseRequest
|
public sealed class CreateWarehouseRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(300)] public string? Address { get; set; }
|
[StringLength(300)] public string? Address { get; set; }
|
||||||
[StringLength(30)] public string? Phone { get; set; }
|
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
|
||||||
[Required] public int BranchId { get; set; }
|
[Required] public int BranchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
|
|||||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(300)] public string? Address { get; set; }
|
[StringLength(300)] public string? Address { get; set; }
|
||||||
[StringLength(30)] public string? Phone { get; set; }
|
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
|
||||||
[Required] public int BranchId { get; set; }
|
[Required] public int BranchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
||||||
|
|
||||||
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
|
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
|
||||||
{
|
=> CallAsync<DeleteAuthHexUserResult>(
|
||||||
var adminApiKey = _config["AuthHex:AdminApiKey"];
|
|
||||||
if (string.IsNullOrEmpty(adminApiKey))
|
|
||||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
|
|
||||||
|
|
||||||
return CallAsync<DeleteAuthHexUserResult>(
|
|
||||||
"user", "deleteUser",
|
"user", "deleteUser",
|
||||||
new { email, mobileNumber, nic, userName = username },
|
new { email, mobileNumber, nic, userName = username },
|
||||||
null, ct,
|
null, ct,
|
||||||
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey });
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
|
||||||
|
=> CallAsync<UserSummaryDto?>(
|
||||||
|
"user", "AdminUpdateUser",
|
||||||
|
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
|
||||||
|
null, ct,
|
||||||
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
|
||||||
|
=> CallAsync<UserSummaryDto?>(
|
||||||
|
"user", "AdminSetUserStatus",
|
||||||
|
new { userId, isActive },
|
||||||
|
null, ct,
|
||||||
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
|
||||||
|
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
|
||||||
|
private Dictionary<string, string> AdminHeaders()
|
||||||
|
{
|
||||||
|
var adminApiKey = _config["AuthHex:AdminApiKey"];
|
||||||
|
if (string.IsNullOrEmpty(adminApiKey))
|
||||||
|
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
|
||||||
|
|
||||||
|
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RecoveryManager ------------------------------------------------
|
// ---- RecoveryManager ------------------------------------------------
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ public interface IAuthHexClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
|
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Admin-only: updates another user's identity fields (not self-service —
|
||||||
|
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
|
||||||
|
/// Gated by AuthHex:AdminApiKey.</summary>
|
||||||
|
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
|
||||||
|
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
|
||||||
|
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
|
||||||
|
|
||||||
// RecoveryManager (POST /api/recovery)
|
// RecoveryManager (POST /api/recovery)
|
||||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
||||||
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService
|
|||||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||||
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||||
|
|
||||||
|
if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.DefaultBranchProtected,
|
||||||
|
$"'{branch.Name}' is the deployment's default branch and cannot be deactivated.",
|
||||||
|
409);
|
||||||
|
|
||||||
branch.Status = status;
|
branch.Status = status;
|
||||||
branch.UpdatedAt = DateTime.UtcNow;
|
branch.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
@@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService
|
|||||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||||
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||||
|
|
||||||
|
if (branch.Code == Branch.DefaultCode)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.DefaultBranchProtected,
|
||||||
|
$"'{branch.Name}' is the deployment's default branch and cannot be deleted.",
|
||||||
|
409);
|
||||||
|
|
||||||
_branches.Remove(branch);
|
_branches.Remove(branch);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
public interface ISalesInvoiceService
|
public interface ISalesInvoiceService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
|
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
|
||||||
|
/// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary>
|
||||||
|
Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -17,16 +17,27 @@ public interface IUserManagementService
|
|||||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
||||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edits DisplayName/Email (and MobileNumber upstream) on both the local shadow row and,
|
||||||
|
/// when linked, the AuthHex identity itself (via the admin-only AdminUpdateUser function).
|
||||||
|
/// </summary>
|
||||||
|
Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flips the local shadow row's status (Active/Inactive) — this is what
|
/// Flips the local shadow row's status (Active/Inactive) — this is what
|
||||||
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
|
/// BranchAccessService and the rest of ERPCore gate on — and, when the user has a
|
||||||
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
|
/// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus
|
||||||
/// is self-service, acting on whoever's bearer token is passed), so the
|
/// function (deactivating additionally invalidates their live AuthHex sessions), so a
|
||||||
/// credential itself stays live upstream; this only revokes the account's
|
/// user shown Inactive here can no longer log in at all, not just inside ERPCore.
|
||||||
/// standing inside ERPCore.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emails the user a time-limited (15 min) password-reset link via AuthHex's existing
|
||||||
|
/// self-service forgot-password flow, triggered on their behalf by an admin.
|
||||||
|
/// </summary>
|
||||||
|
Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
|||||||
public const string ApprovalNavCode = "products.price-adjustments";
|
public const string ApprovalNavCode = "products.price-adjustments";
|
||||||
|
|
||||||
private readonly IRepository<PriceAdjustment> _adjustments;
|
private readonly IRepository<PriceAdjustment> _adjustments;
|
||||||
|
private readonly IRepository<PriceAdjustmentLine> _lines;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ItemPriceHistory> _history;
|
private readonly IRepository<ItemPriceHistory> _history;
|
||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
@@ -36,11 +37,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
|||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public PriceAdjustmentService(
|
public PriceAdjustmentService(
|
||||||
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history,
|
IRepository<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items,
|
||||||
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals,
|
IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> config,
|
||||||
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers,
|
||||||
|
ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
|
_lines = lines;
|
||||||
_items = items;
|
_items = items;
|
||||||
_history = history;
|
_history = history;
|
||||||
_users = users;
|
_users = users;
|
||||||
@@ -92,10 +95,14 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
|||||||
|
|
||||||
var ids = request.Lines.Select(l => l.ItemId).ToList();
|
var ids = request.Lines.Select(l => l.ItemId).ToList();
|
||||||
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||||
var pendingItemIds = await _adjustments.Query().AsNoTracking()
|
// Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a
|
||||||
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
|
// plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that
|
||||||
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo }))
|
// Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this
|
||||||
.Where(x => ids.Contains(x.ItemId))
|
// predates — 2026-10-16 MySQL-port regression).
|
||||||
|
var pendingItemIds = await _lines.Query().AsNoTracking()
|
||||||
|
.Where(l => ids.Contains(l.ItemId)
|
||||||
|
&& (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval))
|
||||||
|
.Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var lines = new List<PriceAdjustmentLine>();
|
var lines = new List<PriceAdjustmentLine>();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
|
|||||||
private readonly IRepository<RolePermission> _rolePermissions;
|
private readonly IRepository<RolePermission> _rolePermissions;
|
||||||
private readonly IRepository<Permission> _permissions;
|
private readonly IRepository<Permission> _permissions;
|
||||||
private readonly IRepository<Employee> _employees;
|
private readonly IRepository<Employee> _employees;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IAuthHexClient _authHex;
|
private readonly IAuthHexClient _authHex;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
@@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService
|
|||||||
|
|
||||||
public RoleService(
|
public RoleService(
|
||||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||||
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex,
|
||||||
|
IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
_rolePermissions = rolePermissions;
|
_rolePermissions = rolePermissions;
|
||||||
_permissions = permissions;
|
_permissions = permissions;
|
||||||
_employees = employees;
|
_employees = employees;
|
||||||
|
_users = users;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_authHex = authHex;
|
_authHex = authHex;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
@@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService
|
|||||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||||
|
|
||||||
|
if (status == EntityStatus.Inactive)
|
||||||
|
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
|
||||||
|
|
||||||
role.Status = status;
|
role.Status = status;
|
||||||
role.UpdatedAt = DateTime.UtcNow;
|
role.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
@@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService
|
|||||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||||
|
|
||||||
|
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
|
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
|
||||||
@@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A role still assigned to at least one local (ERPCore shadow) user cannot be
|
||||||
|
/// deactivated or deleted — that user would be left pointing at a role they can no
|
||||||
|
/// longer effectively hold. Reassign them to a different role first.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureNotAssignedToAnyUserAsync(int roleId, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var assignedUserCount = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId == roleId, ct);
|
||||||
|
if (assignedUserCount > 0)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.RoleInUse,
|
||||||
|
$"This role is assigned to {assignedUserCount} user(s) and cannot be {action}. Reassign them to a different role first.",
|
||||||
|
409);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
|
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||||
|
|||||||
@@ -41,13 +41,37 @@ public sealed class SalesConfigService : ISalesConfigService
|
|||||||
|
|
||||||
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
var config = await LoadAsync(ct);
|
||||||
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
|
||||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
// "Allow cash customer" can be on with no customer linked — the flag was set outside
|
||||||
|
// UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer
|
||||||
|
// because there's no id to offer, so link (or create) CUST-CASH here instead.
|
||||||
|
if (config.AllowCashCustomer && config.CashCustomerId is null)
|
||||||
|
{
|
||||||
|
var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct);
|
||||||
|
if (tracked is not null && tracked.CashCustomerId is null)
|
||||||
|
{
|
||||||
|
tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// A concurrent save got there first — whatever it stored is re-read below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config = await LoadAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<SalesConfig> LoadAsync(CancellationToken ct) =>
|
||||||
|
await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||||
|
|
||||||
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,10 +53,44 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
|
|
||||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
|
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var term = query.Q.Trim();
|
var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
|
||||||
|
// Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule.
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
var totals = await filtered
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Count = g.Count(),
|
||||||
|
Net = g.Sum(x => x.GrandTotal),
|
||||||
|
Paid = g.Sum(x => x.PaidAmount),
|
||||||
|
Balance = g.Sum(x => x.BalanceAmount > 0 ? x.BalanceAmount : 0),
|
||||||
|
Unpaid = g.Count(x => x.BalanceAmount > 0),
|
||||||
|
Overdue = g.Count(x => x.BalanceAmount > 0 && x.InvoiceDate < today),
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return totals is null
|
||||||
|
? new SalesInvoiceListTotalsDto(0, 0m, 0m, 0m, 0, 0)
|
||||||
|
: new SalesInvoiceListTotalsDto(totals.Count, totals.Net, totals.Paid, totals.Balance, totals.Unpaid, totals.Overdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary>
|
||||||
|
private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var term = search.Trim();
|
||||||
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
|
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
|
||||||
}
|
}
|
||||||
if (status is not null) q = q.Where(x => x.Status == status);
|
if (status is not null) q = q.Where(x => x.Status == status);
|
||||||
@@ -72,11 +106,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
// paid=true: nothing left to pay; paid=false: a balance is still due.
|
// paid=true: nothing left to pay; paid=false: a balance is still due.
|
||||||
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
|
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
|
||||||
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
|
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
|
||||||
|
return q;
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
|
||||||
|
|
||||||
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
return Map(user);
|
return Map(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||||
|
?? throw new NotFoundException($"User {userId} was not found.");
|
||||||
|
|
||||||
|
var displayName = request.DisplayName.Trim();
|
||||||
|
var email = request.Email.Trim();
|
||||||
|
|
||||||
|
// Source of truth first — if AuthHex rejects this (e.g. email already taken by
|
||||||
|
// another identity), the local row must not drift out of sync with it.
|
||||||
|
if (user.AuthUserId is Guid authUserId)
|
||||||
|
{
|
||||||
|
await _authHex.AdminUpdateUserAsync(authUserId, new AdminUpdateUserRequest
|
||||||
|
{
|
||||||
|
FullName = displayName,
|
||||||
|
Email = email,
|
||||||
|
MobileNumber = request.MobileNumber
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.DisplayName = displayName;
|
||||||
|
user.Email = email;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(user);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
|
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||||
?? throw new NotFoundException($"User {userId} was not found.");
|
?? throw new NotFoundException($"User {userId} was not found.");
|
||||||
|
|
||||||
|
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
|
||||||
|
// could still log in directly against AuthHex.
|
||||||
|
if (user.AuthUserId is Guid authUserId)
|
||||||
|
await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct);
|
||||||
|
|
||||||
user.Status = status;
|
user.Status = status;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Map(user);
|
return Map(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var user = await _users.GetByIdAsync(userId, ct)
|
||||||
|
?? throw new NotFoundException($"User {userId} was not found.");
|
||||||
|
if (string.IsNullOrWhiteSpace(user.Email))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "This user has no email address on file.", 422);
|
||||||
|
|
||||||
|
var result = await _authHex.ForgotPasswordAsync(new ForgotPasswordRequest
|
||||||
|
{
|
||||||
|
Identifier = user.Email,
|
||||||
|
UseResetLink = true
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return new SendResetLinkResponse(result.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public static class ErrorCodes
|
|||||||
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
||||||
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
||||||
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
|
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
|
||||||
|
public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED";
|
||||||
|
|
||||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
||||||
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
|
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:5011"
|
"BaseUrl": "http://localhost:5011",
|
||||||
|
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
|
||||||
},
|
},
|
||||||
"GeneralLedgerService": {
|
"GeneralLedgerService": {
|
||||||
"BaseUrl": "https://localhost:7024/api/v1/"
|
"BaseUrl": "https://localhost:7024/api/v1/"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"SigningKey": ""
|
"SigningKey": ""
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:6703"
|
"BaseUrl": "http://localhost:6703",
|
||||||
|
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
|
||||||
},
|
},
|
||||||
"GeneralLedgerService": {
|
"GeneralLedgerService": {
|
||||||
"BaseUrl": "http://localhost:6704/api/v1/",
|
"BaseUrl": "http://localhost:6704/api/v1/",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
|
import { ArrowLeft, CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
|
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
type AccountTypeFilter = CashBankAccountType | "Both"
|
type AccountTypeFilter = CashBankAccountType | "Both"
|
||||||
@@ -27,23 +31,25 @@ export default function BankAccountsPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
|
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
|
||||||
|
|
||||||
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
|
const [editTarget, setEditTarget] = useState<CashAndBankAccountDto | null>(null)
|
||||||
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
|
const [editName, setEditName] = useState("")
|
||||||
useEffect(() => {
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
let cancelled = false
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [statusPendingId, setStatusPendingId] = useState<number | null>(null)
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
function load() {
|
||||||
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
|
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
|
||||||
.then(([banks, gl]) => {
|
.then(([banks, gl]) => {
|
||||||
if (cancelled) return
|
|
||||||
setAccounts(banks)
|
setAccounts(banks)
|
||||||
setGlAccounts(gl.items)
|
setGlAccounts(gl.items)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => setError(errorMessage(err)))
|
||||||
if (!cancelled) setError(errorMessage(err))
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
}
|
||||||
}, [accountType])
|
|
||||||
|
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
|
||||||
|
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
|
||||||
|
useEffect(load, [accountType])
|
||||||
|
|
||||||
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
|
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
|
||||||
|
|
||||||
@@ -64,6 +70,57 @@ export default function BankAccountsPage() {
|
|||||||
})
|
})
|
||||||
}, [accounts, search, glAccountsById])
|
}, [accounts, search, glAccountsById])
|
||||||
|
|
||||||
|
function openEdit(a: CashAndBankAccountDto) {
|
||||||
|
setEditTarget(a)
|
||||||
|
setEditName(a.accountName)
|
||||||
|
setEditError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename() {
|
||||||
|
if (!editTarget) return
|
||||||
|
if (!editName.trim()) {
|
||||||
|
setEditError("Account name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await bankAccountsApi.updateCash(editTarget.accountId, editName.trim())
|
||||||
|
toast.success("Cash account renamed")
|
||||||
|
setEditTarget(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(a: CashAndBankAccountDto) {
|
||||||
|
setStatusPendingId(a.accountId)
|
||||||
|
try {
|
||||||
|
await bankAccountsApi.setCashStatus(a.accountId, !a.isActive)
|
||||||
|
toast.success(a.isActive ? "Cash account deactivated" : "Cash account activated")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setStatusPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(a: CashAndBankAccountDto) {
|
||||||
|
setDeletingId(a.accountId)
|
||||||
|
try {
|
||||||
|
await bankAccountsApi.deleteCash(a.accountId)
|
||||||
|
toast.success("Cash account deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete cash account", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -167,6 +224,48 @@ export default function BankAccountsPage() {
|
|||||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
{a.accountType === CashBankAccountType.Cash ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label={`Edit ${a.accountName}`} title={`Edit ${a.accountName}`}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(a.isActive ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
|
||||||
|
aria-label={a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
|
||||||
|
title={a.isDefault && a.isActive ? "The default cash account can't be deactivated" : a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
|
||||||
|
disabled={statusPendingId === a.accountId || (a.isDefault === true && a.isActive === true)}
|
||||||
|
onClick={() => toggleStatus(a)}
|
||||||
|
>
|
||||||
|
{a.isActive ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${a.accountName}`}
|
||||||
|
title={a.isDefault ? "The default cash account can't be deleted" : `Delete ${a.accountName}`}
|
||||||
|
disabled={deletingId === a.accountId || a.isDefault === true}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${a.accountName}?`}
|
||||||
|
description="This permanently removes the cash account. Cash accounts referenced by posted journal entries cannot be deleted."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(a)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -176,10 +275,10 @@ export default function BankAccountsPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
Editing isn't available yet — the General Ledger service has no update endpoint for bank accounts.
|
||||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
@@ -187,6 +286,34 @@ export default function BankAccountsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(v) => { if (!v) setEditTarget(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Rename cash account</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget?.isDefault
|
||||||
|
? "This is the default cash account — only its name can be changed."
|
||||||
|
: "Update this cash account's display name."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!editError}>
|
||||||
|
<FieldLabel htmlFor="cash-account-name">Account name</FieldLabel>
|
||||||
|
<Input id="cash-account-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editError} />
|
||||||
|
<FieldError errors={[editError ? { message: editError } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditTarget(null)} disabled={editSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleRename} disabled={editSubmitting}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
/** What the edit dialog is editing — a category (no GL code) or a title. */
|
|
||||||
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
|
|
||||||
|
|
||||||
export default function ExpenseSetupPage() {
|
export default function ExpenseSetupPage() {
|
||||||
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
|
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
const [addTitleOpen, setAddTitleOpen] = useState(false)
|
const [addTitleOpen, setAddTitleOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Editing>(null)
|
const [editing, setEditing] = useState<ExpenseTitle | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
expenseSetupApi
|
expenseSetupApi
|
||||||
@@ -45,10 +42,10 @@ export default function ExpenseSetupPage() {
|
|||||||
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
|
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1>
|
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Expense Setup</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-sm text-muted-foreground sm:text-base">
|
||||||
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
|
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() {
|
|||||||
|
|
||||||
{categories && (
|
{categories && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
|
||||||
<nav className="flex gap-2 overflow-x-auto lg:flex-col" aria-label="Expense categories">
|
{/* Phones: the (fixed, four) categories as a 2×2 grid — no sideways scrolling to find one.
|
||||||
|
Tablets: one scrollable row. Desktop: a side list. */}
|
||||||
|
<nav className="grid grid-cols-2 gap-2 sm:flex sm:overflow-x-auto lg:flex-col" aria-label="Expense categories">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.expenseCategoryId}
|
key={c.expenseCategoryId}
|
||||||
onClick={() => setSelectedId(c.expenseCategoryId)}
|
onClick={() => setSelectedId(c.expenseCategoryId)}
|
||||||
|
aria-current={c.expenseCategoryId === selectedId ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center justify-between gap-2 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
|
"flex min-h-12 min-w-0 shrink-0 items-center justify-between gap-2 rounded-xl border px-3 py-3 text-left text-sm transition-colors sm:px-4",
|
||||||
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
|
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
|
||||||
c.status === "Inactive" && "opacity-60"
|
c.status === "Inactive" && "opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{c.name}</span>
|
<span className="min-w-0 truncate font-medium">{c.name}</span>
|
||||||
<span className="text-xs tabular-nums">{c.titles.length}</span>
|
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums">{c.titles.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -85,17 +85,11 @@ export default function ExpenseSetupPage() {
|
|||||||
{selected.isSystem && <SystemBadge />}
|
{selected.isSystem && <SystemBadge />}
|
||||||
{selected.status === "Inactive" && <InactiveBadge />}
|
{selected.status === "Inactive" && <InactiveBadge />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<Button size="sm" className="h-10 w-full sm:h-8 sm:w-auto" onClick={() => setAddTitleOpen(true)}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditing({ kind: "category", row: selected })}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
Edit category
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setAddTitleOpen(true)}>
|
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add title
|
Add title
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected.code === "SALARIES" && (
|
{selected.code === "SALARIES" && (
|
||||||
<p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
<p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||||
@@ -106,7 +100,36 @@ export default function ExpenseSetupPage() {
|
|||||||
{selected.titles.length === 0 ? (
|
{selected.titles.length === 0 ? (
|
||||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
|
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
|
{/* Phones: one card per title — tap anywhere on it to edit. */}
|
||||||
|
<ul className="flex flex-col gap-2 p-3 md:hidden">
|
||||||
|
{selected.titles.map((t) => (
|
||||||
|
<li key={t.expenseTitleId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(t)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-lg border p-3 text-left hover:bg-muted/40",
|
||||||
|
t.status === "Inactive" && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5 font-medium">
|
||||||
|
<span className="truncate">{t.name}</span>
|
||||||
|
{t.isSystem && <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Built-in" />}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono tabular-nums">GL {t.glExpenseAccountCode}</span>
|
||||||
|
{t.status === "Inactive" && <InactiveBadge />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Pencil className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="sr-only">Edit {t.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -128,7 +151,7 @@ export default function ExpenseSetupPage() {
|
|||||||
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
|
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
|
||||||
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
|
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">
|
<TableCell className="px-4 py-3 text-right">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditing({ kind: "title", row: t })}>
|
<Button variant="ghost" size="sm" onClick={() => setEditing(t)}>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +203,7 @@ function InactiveBadge() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) {
|
function EditDialog({ editing, onClose, onSaved }: { editing: ExpenseTitle | null; onClose: () => void; onSaved: () => void }) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [glCode, setGlCode] = useState("")
|
const [glCode, setGlCode] = useState("")
|
||||||
const [active, setActive] = useState(true)
|
const [active, setActive] = useState(true)
|
||||||
@@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
|
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
|
||||||
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null
|
const key = editing?.expenseTitleId ?? null
|
||||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
const [resetFor, setResetFor] = useState<number | null>(null)
|
||||||
if (key !== resetFor) {
|
if (key !== resetFor) {
|
||||||
setResetFor(key)
|
setResetFor(key)
|
||||||
if (editing) {
|
if (editing) {
|
||||||
setName(editing.row.name)
|
setName(editing.name)
|
||||||
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "")
|
setGlCode(editing.glExpenseAccountCode)
|
||||||
setActive(editing.row.status === "Active")
|
setActive(editing.status === "Active")
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editing) return null
|
if (!editing) return null
|
||||||
const isSystem = editing.row.isSystem
|
const isSystem = editing.isSystem
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!editing) return
|
if (!editing) return
|
||||||
if (!name.trim()) return setError("Enter a name")
|
if (!name.trim()) return setError("Enter a name")
|
||||||
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code")
|
if (!glCode.trim()) return setError("Enter the GL expense account code")
|
||||||
const status: EntityStatus = active ? "Active" : "Inactive"
|
const status: EntityStatus = active ? "Active" : "Inactive"
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (editing.kind === "category") {
|
await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
|
||||||
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
|
|
||||||
} else {
|
|
||||||
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
|
|
||||||
}
|
|
||||||
toast.success("Saved", name.trim())
|
toast.success("Saved", name.trim())
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
|
|||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle>
|
<DialogTitle>Edit title</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -236,13 +256,11 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
|
|||||||
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
|
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
|
||||||
{isSystem && <FieldDescription>Built-in names can't be changed.</FieldDescription>}
|
{isSystem && <FieldDescription>Built-in names can't be changed.</FieldDescription>}
|
||||||
</Field>
|
</Field>
|
||||||
{editing.kind === "title" && (
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
|
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
|
||||||
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
|
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
|
||||||
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
|
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
<Field orientation="horizontal">
|
<Field orientation="horizontal">
|
||||||
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
|
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
|
||||||
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
|
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
|
||||||
@@ -250,10 +268,10 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
|
|||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={onClose} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={saving}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -236,11 +236,13 @@ export default function CustomersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New customer</DialogTitle>
|
<DialogTitle>New customer</DialogTitle>
|
||||||
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
|
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Only the fields scroll — title and Create/Cancel stay visible on a phone. */}
|
||||||
|
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4 pb-1">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
|
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
|
||||||
@@ -263,10 +265,10 @@ export default function CustomersPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
||||||
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
||||||
@@ -281,7 +283,7 @@ export default function CustomersPage() {
|
|||||||
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
|
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
|
||||||
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
|
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
|
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
|
||||||
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
|
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
|
||||||
@@ -291,7 +293,7 @@ export default function CustomersPage() {
|
|||||||
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
|
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||||
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||||
@@ -318,11 +320,12 @@ export default function CustomersPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
</div>
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
<div className="-mx-4 flex shrink-0 flex-col-reverse gap-3 border-t px-4 pt-3 sm:mx-0 sm:flex-row sm:justify-center sm:border-t-0 sm:px-0 sm:pt-2">
|
||||||
|
<Button variant="outline" className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
<Button className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||||
{submitting ? "Creating…" : "Create"}
|
{submitting ? "Creating…" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Banknote, CalendarCheck, CalendarClock, IdCard, SlidersHorizontal, Users } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
// Same entries (and permission codes) as the HRM group in the sidebar.
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: "Employees",
|
||||||
|
description: "Employee records, departments and designations.",
|
||||||
|
href: "/dashboard/hrm/employees",
|
||||||
|
code: "hrm.employees",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Attendance",
|
||||||
|
description: "Daily attendance and time records.",
|
||||||
|
href: "/dashboard/hrm/attendance",
|
||||||
|
code: "hrm.attendance",
|
||||||
|
icon: CalendarCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Leave",
|
||||||
|
description: "Leave requests, approvals and balances.",
|
||||||
|
href: "/dashboard/hrm/leave",
|
||||||
|
code: "hrm.leave",
|
||||||
|
icon: CalendarClock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Payroll",
|
||||||
|
description: "Salary runs and payslips.",
|
||||||
|
href: "/dashboard/hrm/payroll",
|
||||||
|
code: "hrm.payroll",
|
||||||
|
icon: Banknote,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
description: "HRM masters and configuration.",
|
||||||
|
href: "/dashboard/hrm/settings",
|
||||||
|
code: "hrm.settings",
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** HRM overview — what the breadcrumb's "HRM" opens; one card per HRM section the user can see. */
|
||||||
|
export default function HrmHubPage() {
|
||||||
|
const { navCodes, isAdmin, loading } = useAuth()
|
||||||
|
// Admins see everything; otherwise the same nav codes that decide the sidebar's HRM entries.
|
||||||
|
const visible = loading ? [] : sections.filter((s) => isAdmin || navCodes.includes(s.code))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
|
||||||
|
<IdCard className="size-4" />
|
||||||
|
HRM
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground sm:text-2xl">HRM</h1>
|
||||||
|
<p className="text-sm text-muted-foreground sm:text-base">Employees, attendance, leave and payroll in one place.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && visible.length === 0 && (
|
||||||
|
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
You don'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import BarChart from "@/components/ui/bar-chart"
|
|||||||
import LineChart from "@/components/ui/line-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
|
|
||||||
const TREND_DAYS = 14
|
const TREND_DAYS = 14
|
||||||
|
const MOBILE_TREND_DAYS = 5
|
||||||
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
|
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
|
||||||
const CHART_MIN_SLOTS = 5
|
const CHART_MIN_SLOTS = 5
|
||||||
|
|
||||||
@@ -112,6 +113,16 @@ export default function DashboardPage() {
|
|||||||
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
|
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
|
||||||
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
|
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
|
||||||
|
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
|
||||||
|
const shownTrend = useMemo(
|
||||||
|
() => ({
|
||||||
|
labels: movementTrend.labels.slice(-trendDays),
|
||||||
|
inData: movementTrend.inData.slice(-trendDays),
|
||||||
|
outData: movementTrend.outData.slice(-trendDays),
|
||||||
|
}),
|
||||||
|
[movementTrend, trendDays]
|
||||||
|
)
|
||||||
const valuationRows = stats?.stockValuationByWarehouse ?? []
|
const valuationRows = stats?.stockValuationByWarehouse ?? []
|
||||||
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
|
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
|
||||||
|
|
||||||
@@ -130,26 +141,28 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
|
||||||
|
compact stacked layout, so they would not match the stat cards on every other screen. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/dashboard/stock/reorder-alerts">
|
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/stock/enquiry">
|
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/receiving/grn">
|
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/procurement/requisitions">
|
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/stock/counts">
|
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/procurement/rfqs">
|
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
|
||||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -199,23 +212,23 @@ export default function DashboardPage() {
|
|||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
Stock Movement Trend (last {TREND_DAYS} days)
|
Stock Movement Trend (last {trendDays} days)
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
movementTrend.inData.some((v) => v > 0) || movementTrend.outData.some((v) => v > 0) ? (
|
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
|
||||||
<div className="h-64 sm:h-72">
|
<div className="h-64 sm:h-72">
|
||||||
<LineChart
|
<LineChart
|
||||||
labels={movementTrend.labels}
|
labels={shownTrend.labels}
|
||||||
datasets={[
|
datasets={[
|
||||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {TREND_DAYS} days.</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ function NewPurchaseReturnContent() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
<Label className="text-base">Goods Receipt Note</Label>
|
<Label className="text-base">Goods Received Note</Label>
|
||||||
<Combobox<number>
|
<Combobox<number>
|
||||||
items={grnOptions}
|
items={grnOptions}
|
||||||
value={grnId}
|
value={grnId}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ import {
|
|||||||
ITEM_IMAGE_MAX_COUNT,
|
ITEM_IMAGE_MAX_COUNT,
|
||||||
itemImageProblem,
|
itemImageProblem,
|
||||||
} from "@/components/products/ItemImagesPanel"
|
} from "@/components/products/ItemImagesPanel"
|
||||||
|
import { AddUomDialog } from "@/components/products/AddUomDialog"
|
||||||
|
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
|
||||||
|
|
||||||
function skuSegment(text: string, maxLen: number): string {
|
function skuSegment(text: string, maxLen: number): string {
|
||||||
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
|
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||||
@@ -118,6 +120,8 @@ export default function NewItemPage() {
|
|||||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||||
/** Defaults to the first UOM once loaded; null only means none exist yet. */
|
/** Defaults to the first UOM once loaded; null only means none exist yet. */
|
||||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||||
|
const [addUomOpen, setAddUomOpen] = useState(false)
|
||||||
|
const [addBrandOpen, setAddBrandOpen] = useState(false)
|
||||||
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
||||||
const [contentQty, setContentQty] = useState("")
|
const [contentQty, setContentQty] = useState("")
|
||||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||||
@@ -216,7 +220,11 @@ export default function NewItemPage() {
|
|||||||
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => {
|
||||||
|
const message = errorMessage(err)
|
||||||
|
setLoadError(message)
|
||||||
|
toast.error("Could not load the form", message)
|
||||||
|
})
|
||||||
// Optional extra: a failure here just means only the default periods are offered.
|
// Optional extra: a failure here just means only the default periods are offered.
|
||||||
warrantyPeriodsApi
|
warrantyPeriodsApi
|
||||||
.list({ status: "Active" })
|
.list({ status: "Active" })
|
||||||
@@ -350,6 +358,7 @@ export default function NewItemPage() {
|
|||||||
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
|
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
|
||||||
if (parsed.error !== undefined) {
|
if (parsed.error !== undefined) {
|
||||||
setCustomPeriodError(parsed.error)
|
setCustomPeriodError(parsed.error)
|
||||||
|
toast.error("Invalid warranty period", parsed.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setItemOnlyWarrantyMonths(parsed.months)
|
setItemOnlyWarrantyMonths(parsed.months)
|
||||||
@@ -375,24 +384,30 @@ export default function NewItemPage() {
|
|||||||
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
|
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
|
||||||
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
|
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
|
||||||
*/
|
*/
|
||||||
|
/** Inline message under the custom-variant box, mirrored as a toast. */
|
||||||
|
function failCustom(message: string) {
|
||||||
|
setCustomError(message)
|
||||||
|
toast.error("Could not add custom variant", message)
|
||||||
|
}
|
||||||
|
|
||||||
function createCustomVariant() {
|
function createCustomVariant() {
|
||||||
const name = customName.trim() || (customIsSize ? "Size" : "")
|
const name = customName.trim() || (customIsSize ? "Size" : "")
|
||||||
const invalid = validateItemTypeName(name).name
|
const invalid = validateItemTypeName(name).name
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
setCustomError(invalid)
|
failCustom(invalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
||||||
setCustomError(`"${name}" already exists — tick it above instead.`)
|
failCustom(`"${name}" already exists — tick it above instead.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (customIsSize && !measurementUnit) {
|
if (customIsSize && !measurementUnit) {
|
||||||
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
|
failCustom("Pick a Measurement unit at the top first — size values are entered in it.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only one dimension may supply the content size, or a variant would have two.
|
// Only one dimension may supply the content size, or a variant would have two.
|
||||||
if (customIsSize && measurableCheckedCount > 0) {
|
if (customIsSize && measurableCheckedCount > 0) {
|
||||||
setCustomError("Only one size variant can be used at a time — untick the other one first.")
|
failCustom("Only one size variant can be used at a time — untick the other one first.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const local: ItemType = {
|
const local: ItemType = {
|
||||||
@@ -413,6 +428,10 @@ export default function NewItemPage() {
|
|||||||
* duplicate pair never reaches state, so nothing downstream has to defend against one.
|
* duplicate pair never reaches state, so nothing downstream has to defend against one.
|
||||||
*/
|
*/
|
||||||
function addValue(itemTypeId: number) {
|
function addValue(itemTypeId: number) {
|
||||||
|
const failValue = (message: string) => {
|
||||||
|
setValueErrors((prev) => ({ ...prev, [itemTypeId]: message }))
|
||||||
|
toast.error("Could not add value", message)
|
||||||
|
}
|
||||||
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
|
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
|
||||||
|
|
||||||
if (!measurable) {
|
if (!measurable) {
|
||||||
@@ -435,11 +454,11 @@ export default function NewItemPage() {
|
|||||||
const qty = Number(raw)
|
const qty = Number(raw)
|
||||||
|
|
||||||
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
||||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
|
failValue("Enter a size greater than 0")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
|
failValue("Select a unit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +467,7 @@ export default function NewItemPage() {
|
|||||||
const key = measureKey(qty, unit)
|
const key = measureKey(qty, unit)
|
||||||
const existing = valuesByCategory[itemTypeId] ?? []
|
const existing = valuesByCategory[itemTypeId] ?? []
|
||||||
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
|
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
|
||||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
|
failValue("That size is already in the list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,13 +727,44 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
const loading = !categories || !brands || !itemTypes || !config
|
const loading = !categories || !brands || !itemTypes || !config
|
||||||
|
|
||||||
|
/** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */
|
||||||
|
function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) {
|
||||||
|
const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}`
|
||||||
|
const invalid = kind === "flat" && !!priceErrors[variantKey]
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={pricesByKey[stateKey] ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setPricesByKey((prev) => ({ ...prev, [stateKey]: value }))
|
||||||
|
if (kind === "flat")
|
||||||
|
setPriceErrors((prev) => {
|
||||||
|
if (!prev[variantKey]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[variantKey]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder="0.00"
|
||||||
|
aria-label={`${kind === "flat" ? "Flat" : kind === "min" ? "Min" : "Max"} price`}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
className={cn("h-10 text-base", className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile-first section shell: tighter padding on phones, roomier from `sm` up. */
|
||||||
|
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
<h1 className="text-xl font-bold text-foreground sm:text-2xl">New Item</h1>
|
||||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
|
<p className="text-sm text-muted-foreground sm:text-base">Category, subcategory, brand, and item variants (FR-MD-01).</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
@@ -726,17 +776,19 @@ export default function NewItemPage() {
|
|||||||
{!loading && baseUomId === null && (
|
{!loading && baseUomId === null && (
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
||||||
No unit of measure exists yet. Items need a base UOM —{" "}
|
No unit of measure exists yet. Items need a base UOM —{" "}
|
||||||
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
|
<button type="button" onClick={() => setAddUomOpen(true)} className="font-semibold underline">
|
||||||
create one first
|
add one now
|
||||||
</Link>
|
</button>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Basic details</h2>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
<Label className="text-base" required>Product name</Label>
|
<Label className="text-base" required>Product name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={productName}
|
value={productName}
|
||||||
@@ -802,12 +854,13 @@ export default function NewItemPage() {
|
|||||||
{config?.brandsEnabled && (
|
{config?.brandsEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Brand (optional)</Label>
|
<Label className="text-base">Brand (optional)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={brandId}
|
value={brandId}
|
||||||
onValueChange={setBrandId}
|
onValueChange={setBrandId}
|
||||||
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
|
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
|
||||||
<SelectValue placeholder="Select brand" />
|
<SelectValue placeholder="Select brand" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -818,82 +871,34 @@ export default function NewItemPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 shrink-0"
|
||||||
|
onClick={() => setAddBrandOpen(true)}
|
||||||
|
aria-label="Add brand"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">New</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Low stock alert (optional)</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={lowStockAt}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLowStockAt(e.target.value)
|
|
||||||
clearErrors("lowStockAt", "reorderQty")
|
|
||||||
}}
|
|
||||||
placeholder="Alert when stock ≤"
|
|
||||||
aria-label="Alert when stock is at or below"
|
|
||||||
aria-invalid={!!errors.lowStockAt}
|
|
||||||
className="h-12! text-base"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={reorderQty}
|
|
||||||
onChange={(e) => {
|
|
||||||
setReorderQty(e.target.value)
|
|
||||||
clearErrors("reorderQty")
|
|
||||||
}}
|
|
||||||
placeholder="Reorder qty"
|
|
||||||
aria-label="Reorder quantity"
|
|
||||||
aria-invalid={!!errors.reorderQty}
|
|
||||||
className="h-12! text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
|
|
||||||
errors.reorderQty ? { message: errors.reorderQty } : undefined,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{lowStockAt.trim()
|
|
||||||
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
|
|
||||||
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Warehouse (optional)</Label>
|
|
||||||
<Select<number | null>
|
|
||||||
value={warehouseId}
|
|
||||||
onValueChange={setWarehouseId}
|
|
||||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue placeholder="Select warehouse" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{warehouses.map((w) => (
|
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
|
||||||
{w.code} — {w.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Units & stock</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base" required>Base UOM</Label>
|
<Label className="text-base" required>Base UOM</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={baseUomId}
|
value={baseUomId}
|
||||||
onValueChange={setBaseUomId}
|
onValueChange={setBaseUomId}
|
||||||
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
|
||||||
<SelectValue placeholder="Select base UOM" />
|
<SelectValue placeholder="Select base UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -904,6 +909,17 @@ export default function NewItemPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 shrink-0"
|
||||||
|
onClick={() => setAddUomOpen(true)}
|
||||||
|
aria-label="Add UOM"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">New</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* The unit size variants are measured in. Picked once here; every size value added
|
{/* The unit size variants are measured in. Picked once here; every size value added
|
||||||
below (custom size variant or a measurement Item Variant) starts in this unit. */}
|
below (custom size variant or a measurement Item Variant) starts in this unit. */}
|
||||||
@@ -1026,11 +1042,88 @@ export default function NewItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
|
||||||
|
{/* Stays side by side even on phones: two short numbers read as one rule. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={lowStockAt}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLowStockAt(e.target.value)
|
||||||
|
clearErrors("lowStockAt", "reorderQty")
|
||||||
|
}}
|
||||||
|
placeholder="Alert at"
|
||||||
|
aria-label="Alert when stock is at or below"
|
||||||
|
aria-invalid={!!errors.lowStockAt}
|
||||||
|
className="h-12! text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={reorderQty}
|
||||||
|
onChange={(e) => {
|
||||||
|
setReorderQty(e.target.value)
|
||||||
|
clearErrors("reorderQty")
|
||||||
|
}}
|
||||||
|
placeholder="Reorder qty"
|
||||||
|
aria-label="Reorder quantity"
|
||||||
|
aria-invalid={!!errors.reorderQty}
|
||||||
|
className="h-12! text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FieldError
|
||||||
|
errors={[
|
||||||
|
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
|
||||||
|
errors.reorderQty ? { message: errors.reorderQty } : undefined,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{lowStockAt.trim()
|
||||||
|
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
|
||||||
|
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Warehouse (optional)</Label>
|
||||||
|
<Select<number | null>
|
||||||
|
value={warehouseId}
|
||||||
|
onValueChange={setWarehouseId}
|
||||||
|
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select warehouse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
|
||||||
{config?.sellingPricesEnabled && (
|
{config?.sellingPricesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className={sectionClass}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1054,8 +1147,8 @@ export default function NewItemPage() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||||
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
<div className="flex flex-col gap-2 sm:min-w-56 sm:flex-1">
|
||||||
<Label className="text-base">Bulk default flat price</Label>
|
<Label className="text-base">Bulk default flat price</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -1071,6 +1164,7 @@ export default function NewItemPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-11 w-full sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const raw = defaultFlatPrice.trim()
|
const raw = defaultFlatPrice.trim()
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
@@ -1096,7 +1190,7 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{config?.warrantyPeriodsEnabled && (
|
{config?.warrantyPeriodsEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className={sectionClass}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1140,7 +1234,7 @@ export default function NewItemPage() {
|
|||||||
value: m,
|
value: m,
|
||||||
}))}
|
}))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
<SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||||
<SelectValue placeholder="Select period" />
|
<SelectValue placeholder="Select period" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1253,7 +1347,7 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{config?.itemImagesEnabled && (
|
{config?.itemImagesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className={sectionClass}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
|
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
|
||||||
@@ -1275,7 +1369,7 @@ export default function NewItemPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removePendingImage(p.url)}
|
onClick={() => removePendingImage(p.url)}
|
||||||
aria-label={`Remove ${p.file.name}`}
|
aria-label={`Remove ${p.file.name}`}
|
||||||
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
|
className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1309,7 +1403,7 @@ export default function NewItemPage() {
|
|||||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||||
item-type reference), so this section IS the enforcement. */}
|
item-type reference), so this section IS the enforcement. */}
|
||||||
{config?.itemTypesEnabled && (
|
{config?.itemTypesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className={sectionClass}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
|
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1329,7 +1423,7 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
{(itemTypes ?? []).map((t) => {
|
{(itemTypes ?? []).map((t) => {
|
||||||
const checked = checkedItemTypeIds.includes(t.itemTypeId)
|
const checked = checkedItemTypeIds.includes(t.itemTypeId)
|
||||||
// Only one dimension may supply the content size, or a variant would have two.
|
// Only one dimension may supply the content size, or a variant would have two.
|
||||||
@@ -1339,7 +1433,7 @@ export default function NewItemPage() {
|
|||||||
key={t.itemTypeId}
|
key={t.itemTypeId}
|
||||||
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
|
"flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||||
blocked ? "opacity-50" : "hover:bg-muted/50"
|
blocked ? "opacity-50" : "hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1362,7 +1456,7 @@ export default function NewItemPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{config?.customVariantsEnabled && !customOpen && (
|
{config?.customVariantsEnabled && !customOpen && (
|
||||||
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
|
<Button type="button" variant="outline" className="h-11" onClick={() => setCustomOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Custom variant
|
Custom variant
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1414,11 +1508,11 @@ export default function NewItemPage() {
|
|||||||
/>
|
/>
|
||||||
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
|
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 sm:ml-auto">
|
<div className="grid grid-cols-2 gap-2 sm:ml-auto sm:flex">
|
||||||
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
|
<Button type="button" variant="ghost" className="h-11 sm:h-9" onClick={closeCustomVariant}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={createCustomVariant}>
|
<Button type="button" className="h-11 sm:h-9" onClick={createCustomVariant}>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1490,9 +1584,15 @@ export default function NewItemPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 shrink-0"
|
||||||
|
onClick={() => addValue(t.itemTypeId)}
|
||||||
|
aria-label={`Add ${t.name}`}
|
||||||
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add
|
<span className="hidden sm:inline">Add</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1509,9 +1609,15 @@ export default function NewItemPage() {
|
|||||||
placeholder={t.name}
|
placeholder={t.name}
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 shrink-0"
|
||||||
|
onClick={() => addValue(t.itemTypeId)}
|
||||||
|
aria-label={`Add ${t.name}`}
|
||||||
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add {t.name}
|
<span className="hidden sm:inline">Add {t.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1525,7 +1631,7 @@ export default function NewItemPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeValue(t.itemTypeId, v.label)}
|
onClick={() => removeValue(t.itemTypeId, v.label)}
|
||||||
className="rounded-full p-0.5 hover:bg-muted"
|
className="rounded-full p-1 hover:bg-muted sm:p-0.5"
|
||||||
aria-label={`Remove ${v.label}`}
|
aria-label={`Remove ${v.label}`}
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
@@ -1544,7 +1650,53 @@ export default function NewItemPage() {
|
|||||||
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
|
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
|
||||||
|
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
|
{/* Phones: one card per variant — a 6+ column table can't fit a narrow screen. */}
|
||||||
|
<ul className="flex flex-col gap-3 md:hidden">
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<li key={variant.key} className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-semibold wrap-break-word">
|
||||||
|
{variant.parts.map((p) => p.value.label).join(" / ")}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm break-all text-muted-foreground">{variant.sku}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeVariant(variant.key)}
|
||||||
|
aria-label={`Remove ${variant.sku}`}
|
||||||
|
className="-mt-1 -mr-1 shrink-0 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Content: </span>
|
||||||
|
{contentLabelFor(variant)}
|
||||||
|
</p>
|
||||||
|
{priceMode === "fixed" && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(["min", "max", "flat"] as const).map((kind) => (
|
||||||
|
<div key={kind} className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{kind === "min" ? "Min" : kind === "max" ? "Max" : "Flat"}
|
||||||
|
</span>
|
||||||
|
{priceInput(variant.key, kind, "w-full")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -1583,51 +1735,10 @@ export default function NewItemPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
<>
|
<>
|
||||||
|
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "min", "w-28")}</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "max", "w-28")}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">
|
<TableCell className="px-3 py-2.5">
|
||||||
<Input
|
{priceInput(variant.key, "flat", "w-28")}
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={pricesByKey[`${variant.key}::min`] ?? ""}
|
|
||||||
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="h-10 w-28 text-base"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-2.5">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={pricesByKey[`${variant.key}::max`] ?? ""}
|
|
||||||
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="h-10 w-28 text-base"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-2.5">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={priceFor(variant.key)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
|
|
||||||
setPriceErrors((prev) => {
|
|
||||||
if (!prev[variant.key]) return prev
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[variant.key]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
placeholder="0.00"
|
|
||||||
aria-invalid={!!priceErrors[variant.key]}
|
|
||||||
className="h-10 w-28 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
|
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
@@ -1649,6 +1760,7 @@ export default function NewItemPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1657,16 +1769,40 @@ export default function NewItemPage() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
|
||||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
{variants.length > 0 && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground sm:mr-auto sm:text-left">
|
||||||
|
{variants.length} variant{variants.length === 1 ? "" : "s"} will be created
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
|
||||||
{submitting ? "Creating…" : "Create Variants"}
|
{submitting ? "Creating…" : "Create Variants"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AddUomDialog
|
||||||
|
open={addUomOpen}
|
||||||
|
onOpenChange={setAddUomOpen}
|
||||||
|
onCreated={(uom) => {
|
||||||
|
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
|
||||||
|
setBaseUomId(uom.uomId)
|
||||||
|
setSubmitError(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AddBrandDialog
|
||||||
|
open={addBrandOpen}
|
||||||
|
onOpenChange={setAddBrandOpen}
|
||||||
|
onCreated={(brand) => {
|
||||||
|
setBrands((prev) => [...(prev ?? []), brand])
|
||||||
|
setBrandId(brand.brandId)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory,
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { FilterBar } from "@/components/ui/filter-bar"
|
import { FilterBar } from "@/components/ui/filter-bar"
|
||||||
@@ -187,7 +188,9 @@ export default function ItemsPage() {
|
|||||||
if (!append) setSelected(new Set())
|
if (!append) setSelected(new Set())
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) setError(errorMessage(err))
|
if (cancelled) return
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not load items", errorMessage(err))
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoadingMore(false)
|
if (!cancelled) setLoadingMore(false)
|
||||||
@@ -344,19 +347,30 @@ export default function ItemsPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||||
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
|
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and
|
||||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
emphasised on desktop; the secondary actions share a row beneath it on mobile. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/products/new"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ size: "lg" }),
|
||||||
|
"order-first col-span-2 h-12 text-base font-semibold shadow-md shadow-primary/25 sm:order-last sm:h-11 sm:px-6"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Item
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "min-w-0")}>
|
||||||
<ListTree className="size-5" />
|
<ListTree className="size-5" />
|
||||||
Category/Brand/UOM
|
<span className="truncate">
|
||||||
|
<span className="sm:hidden">Masters</span>
|
||||||
|
<span className="hidden sm:inline">Category/Brand/UOM</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
||||||
<Download className="size-5" />
|
<Download className="size-5" />
|
||||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
New Item
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -475,7 +489,7 @@ export default function ItemsPage() {
|
|||||||
<Package className="size-12 text-muted-foreground" />
|
<Package className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
|
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
|
||||||
{!hasFilters && (
|
{!hasFilters && (
|
||||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }), "shadow-md shadow-primary/25")}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Item
|
New Item
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||||
|
import { SerialRanges } from "@/components/receiving/SerialRanges"
|
||||||
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||||
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
|
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
|
||||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||||
|
|
||||||
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
|
/** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */
|
||||||
function LineTrackingInfo({ line }: { line: GrnLine }) {
|
function LineTrackingInfo({ line }: { line: GrnLine }) {
|
||||||
const serials = line.serials ?? []
|
const serials = line.serials ?? []
|
||||||
if (!line.batchNo && serials.length === 0) return null
|
if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
{line.batchNo && (
|
{line.batchNo && (
|
||||||
@@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) {
|
|||||||
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
|
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{serials.length > 0 && (
|
<SerialRanges label="serial number" values={serials.map((s) => s.serialNo)} />
|
||||||
<details>
|
<SerialRanges label="warranty number" values={line.warrantyNumbers.map((w) => w.warrantyNo)} />
|
||||||
<summary className="cursor-pointer select-none">
|
|
||||||
{serials.length} serial number{serials.length === 1 ? "" : "s"}
|
|
||||||
</summary>
|
|
||||||
<ul className="mt-1 grid max-h-48 grid-cols-2 gap-x-4 overflow-y-auto font-mono">
|
|
||||||
{serials.map((s) => (
|
|
||||||
<li key={s.serialNo}>{s.serialNo}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -112,7 +103,13 @@ export default function GrnDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(grnId)) return
|
if (!Number.isFinite(grnId)) return
|
||||||
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
|
grnsApi
|
||||||
|
.get(grnId)
|
||||||
|
.then(setGrn)
|
||||||
|
.catch((err) => {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not load GRN", errorMessage(err))
|
||||||
|
})
|
||||||
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
|
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
|
||||||
.then(([it, uo]) => {
|
.then(([it, uo]) => {
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
@@ -258,11 +255,6 @@ export default function GrnDetailPage() {
|
|||||||
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
|
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
|
||||||
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
|
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{line.warrantyNumbers.length > 0 && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<LineTrackingInfo line={line} />
|
<LineTrackingInfo line={line} />
|
||||||
</div>
|
</div>
|
||||||
<HoldStatusBadge status={line.holdStatus} />
|
<HoldStatusBadge status={line.holdStatus} />
|
||||||
@@ -378,14 +370,6 @@ export default function GrnDetailPage() {
|
|||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||||
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
|
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{line.warrantyNumbers.length > 0 && (
|
|
||||||
<p
|
|
||||||
className="mt-1 text-xs text-muted-foreground"
|
|
||||||
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
|
|
||||||
>
|
|
||||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<LineTrackingInfo line={line} />
|
<LineTrackingInfo line={line} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||||
|
|||||||
@@ -289,7 +289,10 @@ export default function NewGrnPage() {
|
|||||||
setVendors(ve.items)
|
setVendors(ve.items)
|
||||||
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
|
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => {
|
||||||
|
setLoadError(errorMessage(err))
|
||||||
|
toast.error("Could not load the GRN form", errorMessage(err))
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -318,6 +321,27 @@ export default function NewGrnPage() {
|
|||||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||||
const visibleWarehouses = warehouses ?? []
|
const visibleWarehouses = warehouses ?? []
|
||||||
|
|
||||||
|
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
|
||||||
|
function failHeader(message: string) {
|
||||||
|
setHeaderError(message)
|
||||||
|
toast.error("Check the GRN details", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function failScan(message: string) {
|
||||||
|
setScanError(message)
|
||||||
|
toast.error("Scan failed", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function failSubmit(message: string) {
|
||||||
|
setSubmitError(message)
|
||||||
|
toast.error("Could not create GRN", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function failWarranty(lineKey: string, message: string) {
|
||||||
|
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message }))
|
||||||
|
toast.error("Could not fill warranty numbers", message)
|
||||||
|
}
|
||||||
|
|
||||||
function switchMode(next: Mode) {
|
function switchMode(next: Mode) {
|
||||||
setMode(next)
|
setMode(next)
|
||||||
setPoId(null)
|
setPoId(null)
|
||||||
@@ -342,7 +366,7 @@ export default function NewGrnPage() {
|
|||||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
||||||
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
||||||
if (openLines.length === 0) {
|
if (openLines.length === 0) {
|
||||||
setHeaderError("This purchase order has no open (unreceived) lines.")
|
failHeader("This purchase order has no open (unreceived) lines.")
|
||||||
setLines([])
|
setLines([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,7 +393,7 @@ export default function NewGrnPage() {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setHeaderError(errorMessage(err))
|
failHeader(errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setPoLoading(false)
|
setPoLoading(false)
|
||||||
}
|
}
|
||||||
@@ -403,9 +427,9 @@ export default function NewGrnPage() {
|
|||||||
addItemLine(result.itemId, scannedBatchNo)
|
addItemLine(result.itemId, scannedBatchNo)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
setScanError(`No item or lot found for "${code}"`)
|
failScan(`No item or lot found for "${code}"`)
|
||||||
} else {
|
} else {
|
||||||
setScanError(errorMessage(err))
|
failScan(errorMessage(err))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false)
|
setScanning(false)
|
||||||
@@ -526,7 +550,7 @@ export default function NewGrnPage() {
|
|||||||
function replaceWarrantyNumbers(lineKey: string, units: number) {
|
function replaceWarrantyNumbers(lineKey: string, units: number) {
|
||||||
const range = warrantyRangeInputs[lineKey]
|
const range = warrantyRangeInputs[lineKey]
|
||||||
if (!range?.from.trim() || !range?.adding.trim()) {
|
if (!range?.from.trim() || !range?.adding.trim()) {
|
||||||
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
|
failWarranty(lineKey, "Enter both a from number and an adding number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
|
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
|
||||||
@@ -536,14 +560,11 @@ export default function NewGrnPage() {
|
|||||||
const to = applyAddingNumber(from, range.adding)
|
const to = applyAddingNumber(from, range.adding)
|
||||||
const seq = buildWarrantyRange(from, to)
|
const seq = buildWarrantyRange(from, to)
|
||||||
if (!seq) {
|
if (!seq) {
|
||||||
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
|
failWarranty(lineKey, "The computed number must share From's prefix and not come before it")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (seq.length > units) {
|
if (seq.length > units) {
|
||||||
setWarrantyAddErrors((prev) => ({
|
failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`)
|
||||||
...prev,
|
|
||||||
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
|
|
||||||
}))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setWarrantyAddErrors((prev) => {
|
setWarrantyAddErrors((prev) => {
|
||||||
@@ -636,23 +657,23 @@ export default function NewGrnPage() {
|
|||||||
poId: mode === "po" ? poId : null,
|
poId: mode === "po" ? poId : null,
|
||||||
})
|
})
|
||||||
if (!header.success) {
|
if (!header.success) {
|
||||||
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.")
|
failHeader(header.error.issues[0]?.message ?? "Check the header fields.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === "direct" && !vendorId) {
|
if (mode === "direct" && !vendorId) {
|
||||||
setHeaderError("Select a vendor for a direct receipt.")
|
failHeader("Select a vendor for a direct receipt.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === "po" && !poId) {
|
if (mode === "po" && !poId) {
|
||||||
setHeaderError("Select a purchase order.")
|
failHeader("Select a purchase order.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (branchingEnabled && !effectiveBranchId) {
|
if (branchingEnabled && !effectiveBranchId) {
|
||||||
setHeaderError("Select a branch for this GRN.")
|
failHeader("Select a branch for this GRN.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
setSubmitError("Add at least one line.")
|
failSubmit("Add at least one line.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +701,12 @@ export default function NewGrnPage() {
|
|||||||
)
|
)
|
||||||
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
|
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
|
||||||
setSubmitError("Fix the highlighted lines before submitting.")
|
setSubmitError("Fix the highlighted lines before submitting.")
|
||||||
|
// The toast names the first problem so it's useful even when that line is scrolled out of view.
|
||||||
|
const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))]
|
||||||
|
toast.error(
|
||||||
|
"Fix the highlighted lines before submitting",
|
||||||
|
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
@@ -97,7 +98,10 @@ export default function GrnListPage() {
|
|||||||
setGrns(res.items)
|
setGrns(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not load GRNs", errorMessage(err))
|
||||||
|
})
|
||||||
.finally(() => setShowMoreLoading(false))
|
.finally(() => setShowMoreLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ export default function GrnListPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
|
<h1 className="text-2xl font-bold text-foreground">Goods Received Notes</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
Receive goods against a purchase order, or record a direct receipt.
|
Receive goods against a purchase order, or record a direct receipt.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||||
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useClientPagination } from "@/hooks/use-client-pagination"
|
||||||
import { maskAccountNumber } from "@/types/bank-deposits"
|
import { maskAccountNumber } from "@/types/bank-deposits"
|
||||||
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
|
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
|
||||||
|
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-w-0 flex-col", className)}>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{label}</span>
|
||||||
|
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
|
||||||
|
const mobileCard = (total = false) =>
|
||||||
|
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
|
||||||
|
|
||||||
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
|
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
|
||||||
function ProfitStatement({ day }: { day: DayEndReportDay }) {
|
function ProfitStatement({ day }: { day: DayEndReportDay }) {
|
||||||
const gross = profitOrLoss(day.grossProfit)
|
const gross = profitOrLoss(day.grossProfit)
|
||||||
@@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) {
|
|||||||
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
|
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
|
||||||
*/
|
*/
|
||||||
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
|
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
|
||||||
|
const depositPage = useClientPagination(deposits, date)
|
||||||
const remaining = day.closingCashInHand ?? 0
|
const remaining = day.closingCashInHand ?? 0
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -125,21 +142,15 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
|
|||||||
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
|
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
|
||||||
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
|
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
|
||||||
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
|
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
|
||||||
<div
|
<StatCard
|
||||||
className={cn(
|
label="Remaining cash in hand"
|
||||||
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
|
value={formatAmount(remaining)}
|
||||||
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border"
|
icon={PiggyBank}
|
||||||
)}
|
hint={remaining > 0 ? "Not yet deposited" : "All cash banked"}
|
||||||
>
|
className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined}
|
||||||
<div className="flex items-start justify-between gap-2">
|
iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined}
|
||||||
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p>
|
valueClassName={remaining > 0 ? "text-warning" : undefined}
|
||||||
<PiggyBank className={cn("size-4", remaining > 0 ? "text-warning" : "text-muted-foreground")} aria-hidden />
|
/>
|
||||||
</div>
|
|
||||||
<p className={cn("text-xl font-bold tracking-tight tabular-nums", remaining > 0 ? "text-warning" : "text-foreground")}>
|
|
||||||
{formatAmount(remaining)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{remaining > 0 ? "Not yet deposited" : "All cash banked"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
|
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
|
||||||
@@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
|
|||||||
{deposits.length === 0 ? (
|
{deposits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
|
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col gap-2 md:hidden">
|
||||||
|
{depositPage.pageRows.map((d) => (
|
||||||
|
<li key={d.bankDepositId} className={mobileCard()}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="font-medium text-primary hover:underline">{d.docNo}</Link>
|
||||||
|
<span className="font-mono font-semibold tabular-nums">{formatAmount(d.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Slip {d.depositSlipNo ?? "—"} · {formatReportDate(d.depositDate)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
|
||||||
|
<span>Total deposited</span>
|
||||||
|
<span className="font-mono tabular-nums">{formatAmount(day.cashDeposited ?? 0)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{deposits.map((d) => (
|
{depositPage.pageRows.map((d) => (
|
||||||
<TableRow key={d.bankDepositId}>
|
<TableRow key={d.bankDepositId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
|
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
|
||||||
@@ -184,6 +217,11 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableFooter>
|
</TableFooter>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
{depositPage.pagination.totalItems > 10 && (
|
||||||
|
<PaginationFooter pagination={depositPage.pagination} onPageChange={depositPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={depositPage.setPageSize} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -272,6 +310,10 @@ export default function DayEndReportPage() {
|
|||||||
const sales = day?.data?.sales ?? []
|
const sales = day?.data?.sales ?? []
|
||||||
const expenses = day?.data?.expenses ?? []
|
const expenses = day?.data?.expenses ?? []
|
||||||
const deposits = day?.data?.deposits ?? []
|
const deposits = day?.data?.deposits ?? []
|
||||||
|
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
|
||||||
|
const salesPage = useClientPagination(sales, date)
|
||||||
|
const expensePage = useClientPagination(expenses, date)
|
||||||
|
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -305,7 +347,7 @@ export default function DayEndReportPage() {
|
|||||||
|
|
||||||
{!s && !day?.error && (
|
{!s && !day?.error && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-[84px] rounded-xl" />)}
|
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -325,27 +367,15 @@ export default function DayEndReportPage() {
|
|||||||
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
|
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
|
||||||
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
|
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
|
||||||
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
|
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
|
||||||
<div
|
<StatCard
|
||||||
className={cn(
|
label="Income"
|
||||||
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
|
value={formatSignedAmount(s.income)}
|
||||||
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"
|
icon={s.income >= 0 ? TrendingUp : TrendingDown}
|
||||||
)}
|
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"}
|
||||||
>
|
className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
|
||||||
<div className="flex items-start justify-between gap-2">
|
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
|
||||||
<p className="text-sm font-semibold text-foreground">Income</p>
|
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
|
||||||
{s.income >= 0
|
/>
|
||||||
? <TrendingUp className="size-4 text-success" aria-hidden />
|
|
||||||
: <TrendingDown className="size-4 text-destructive" aria-hidden />}
|
|
||||||
</div>
|
|
||||||
<p className={cn("text-xl font-bold tracking-tight tabular-nums", s.income >= 0 ? "text-success" : "text-destructive")}>
|
|
||||||
{formatSignedAmount(s.income)}
|
|
||||||
</p>
|
|
||||||
{s.income < 0 ? (
|
|
||||||
<p className="text-xs font-semibold text-destructive">Loss — expenses are more than sales</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">Sales − expenses</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
|
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
|
||||||
@@ -361,7 +391,48 @@ export default function DayEndReportPage() {
|
|||||||
{sales.length === 0 ? (
|
{sales.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
|
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
|
<>
|
||||||
|
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
|
||||||
|
{salesPage.pageRows.map((row) => (
|
||||||
|
<li key={`${row.docType}-${row.docId}`} className={mobileCard()}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="min-w-0">
|
||||||
|
{row.docType === "Invoice" ? (
|
||||||
|
<Link href={`/dashboard/sales/invoices/${row.docId}`} className="font-medium text-primary hover:underline">{row.docNo}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{row.docNo}</span>
|
||||||
|
)}
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · {row.docType} · {row.customerName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-right">
|
||||||
|
<span className="block font-mono font-semibold tabular-nums">{formatAmount(row.total)}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{row.paidBy}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 border-t pt-2">
|
||||||
|
<KV label="Paid">{formatAmount(row.paid)}</KV>
|
||||||
|
<KV label="Due">{formatAmount(row.due, true)}</KV>
|
||||||
|
<KV label="Cost">{formatAmount(row.cost)}</KV>
|
||||||
|
<KV label="Profit" className={cn(row.grossProfit < 0 && "text-destructive")}>{formatAmount(row.grossProfit)}</KV>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={mobileCard(true)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="font-mono tabular-nums">{formatAmount(s.totalSales)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 border-t pt-2">
|
||||||
|
<KV label="Paid">{formatAmount(s.paidAmount)}</KV>
|
||||||
|
<KV label="Due">{formatAmount(s.dueAmount)}</KV>
|
||||||
|
<KV label="Cost">{formatAmount(s.costOfGoodsSold)}</KV>
|
||||||
|
<KV label="Profit">{formatAmount(s.grossProfit)}</KV>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-card">
|
<TableHeader className="sticky top-0 bg-card">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -378,7 +449,7 @@ export default function DayEndReportPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sales.map((row) => (
|
{salesPage.pageRows.map((row) => (
|
||||||
<TableRow key={`${row.docType}-${row.docId}`}>
|
<TableRow key={`${row.docType}-${row.docId}`}>
|
||||||
<TableCell className="tabular-nums">
|
<TableCell className="tabular-nums">
|
||||||
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
@@ -415,6 +486,10 @@ export default function DayEndReportPage() {
|
|||||||
</TableFooter>
|
</TableFooter>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
{salesPage.pagination.totalItems > 10 && (
|
||||||
|
<PaginationFooter pagination={salesPage.pagination} onPageChange={salesPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={salesPage.setPageSize} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -423,6 +498,29 @@ export default function DayEndReportPage() {
|
|||||||
{expenses.length === 0 ? (
|
{expenses.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
|
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col gap-2 md:hidden">
|
||||||
|
{expensePage.pageRows.map((e) => (
|
||||||
|
<li key={e.expenseId} className={mobileCard()}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="font-medium text-primary hover:underline">{e.docNo}</Link>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{e.categoryName} · {e.titleName}</span>
|
||||||
|
{e.payee && <span className="block truncate text-xs text-muted-foreground">Payee: {e.payee}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-right">
|
||||||
|
<span className="block font-mono font-semibold tabular-nums">{formatAmount(e.amount)}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">Paid {formatAmount(e.paidAmount)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="font-mono tabular-nums">{formatAmount(s.expenseTotal)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -435,7 +533,7 @@ export default function DayEndReportPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{expenses.map((e) => (
|
{expensePage.pageRows.map((e) => (
|
||||||
<TableRow key={e.expenseId}>
|
<TableRow key={e.expenseId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
|
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
|
||||||
@@ -456,6 +554,11 @@ export default function DayEndReportPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableFooter>
|
</TableFooter>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
{expensePage.pagination.totalItems > 10 && (
|
||||||
|
<PaginationFooter pagination={expensePage.pagination} onPageChange={expensePage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={expensePage.setPageSize} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {
|
|||||||
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
||||||
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
|
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="der-from">From</Label>
|
<Label htmlFor="der-from">From</Label>
|
||||||
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-44" />
|
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="der-to">To</Label>
|
<Label htmlFor="der-to">To</Label>
|
||||||
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-44" />
|
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={downloadRange} disabled={!range?.data || downloading !== null}>
|
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
|
||||||
<FileDown className="size-4" />
|
<FileDown className="size-4" />
|
||||||
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
|
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -493,7 +596,7 @@ export default function DayEndReportPage() {
|
|||||||
|
|
||||||
{!rangeError && range?.data && (
|
{!rangeError && range?.data && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||||
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
|
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
|
||||||
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
|
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -510,9 +613,64 @@ export default function DayEndReportPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
|
||||||
|
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
|
||||||
<DayEndIncomeChart days={range.data.days} />
|
<DayEndIncomeChart days={range.data.days} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
|
{/* Phones: one card per day (newest first), tap to show that day above. */}
|
||||||
|
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
|
||||||
|
{dayPage.pageRows.map((d) => {
|
||||||
|
const p = profitOrLoss(d.netProfit)
|
||||||
|
return (
|
||||||
|
<li key={d.date}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => d.date && setDate(d.date)}
|
||||||
|
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium">{formatReportDate(d.date)}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-right", p.isLoss && "text-destructive")}>
|
||||||
|
<span className="block text-xs">{p.label}</span>
|
||||||
|
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||||
|
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
|
||||||
|
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
|
||||||
|
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
|
||||||
|
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
|
||||||
|
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
|
||||||
|
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
|
||||||
|
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
|
||||||
|
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<li className={mobileCard(true)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Total · {range.data.totals.salesCount} sales</span>
|
||||||
|
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
|
||||||
|
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||||
|
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
|
||||||
|
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
|
||||||
|
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
|
||||||
|
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
|
||||||
|
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-card">
|
<TableHeader className="sticky top-0 bg-card">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -531,7 +689,7 @@ export default function DayEndReportPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{[...range.data.days].reverse().map((d) => (
|
{dayPage.pageRows.map((d) => (
|
||||||
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
|
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
|
||||||
<TableCell>{formatReportDate(d.date)}</TableCell>
|
<TableCell>{formatReportDate(d.date)}</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
|
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
|
||||||
@@ -568,6 +726,9 @@ export default function DayEndReportPage() {
|
|||||||
</TableFooter>
|
</TableFooter>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
{dayPage.pagination.totalItems > 10 && (
|
||||||
|
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { salesDayEndApi } from "@/lib/api/sales-day-end"
|
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatAmount } from "@/lib/format"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { ManagedUser } from "@/types/users"
|
import { ManagedUser } from "@/types/users"
|
||||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
|
import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales"
|
||||||
|
|
||||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
@@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
function todayIso(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SalesDateRange = "All" | "Today" | "7d" | "30d"
|
type SalesDateRange = "All" | "Today" | "7d" | "30d"
|
||||||
|
|
||||||
@@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
|||||||
{ value: "Paid", label: "Fully paid" },
|
{ value: "Paid", label: "Fully paid" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SalesDayEndPage() {
|
export default function ApprovedInvoicesPage() {
|
||||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
|
||||||
const [businessDate, setBusinessDate] = useState(todayIso())
|
|
||||||
|
|
||||||
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null)
|
// Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [report, setReport] = useState<SalesDayEnd | null>(null)
|
|
||||||
const [closing, setClosing] = useState(false)
|
|
||||||
|
|
||||||
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
|
|
||||||
// business date above): a cashier (null follows the cashier picked above, "All" shows every
|
|
||||||
// cashier's sales) and a date range.
|
// cashier's sales) and a date range.
|
||||||
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
|
const [salesCashier, setSalesCashier] = useState<number | "All">("All")
|
||||||
const effectiveSalesCashier = salesCashier ?? cashierUserId
|
const effectiveSalesCashier = salesCashier
|
||||||
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
|
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
|
||||||
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
||||||
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
||||||
const [salesSearchInput, setSalesSearchInput] = useState("")
|
const [salesSearchInput, setSalesSearchInput] = useState("")
|
||||||
@@ -135,30 +120,10 @@ export default function SalesDayEndPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usersApi
|
usersApi
|
||||||
.list({ pageSize: 200 })
|
.list({ pageSize: 200 })
|
||||||
.then((res) => {
|
.then((res) => setCashiers(res.items))
|
||||||
setCashiers(res.items)
|
|
||||||
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function loadPreview() {
|
|
||||||
if (!cashierUserId) return
|
|
||||||
setReport(null)
|
|
||||||
setPreviewError(null)
|
|
||||||
setPreviewLoading(true)
|
|
||||||
salesDayEndApi
|
|
||||||
.preview(cashierUserId, businessDate)
|
|
||||||
.then(setPreview)
|
|
||||||
.catch((err) => setPreviewError(errorMessage(err)))
|
|
||||||
.finally(() => setPreviewLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPreview()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [cashierUserId, businessDate])
|
|
||||||
|
|
||||||
// Debounced search; a new term starts again from page 1.
|
// Debounced search; a new term starts again from page 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -170,7 +135,6 @@ export default function SalesDayEndPage() {
|
|||||||
}, [salesSearchInput])
|
}, [salesSearchInput])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effectiveSalesCashier === null) return
|
|
||||||
salesApi
|
salesApi
|
||||||
.listInvoices({
|
.listInvoices({
|
||||||
page: salesPage,
|
page: salesPage,
|
||||||
@@ -186,6 +150,41 @@ export default function SalesDayEndPage() {
|
|||||||
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
|
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
|
||||||
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
|
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
|
||||||
|
|
||||||
|
// Stat cards: the same filters as the table, totalled over every match (not one page). Keyed
|
||||||
|
// like the table so a change of dropdown — or a payment — refreshes them.
|
||||||
|
const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|")
|
||||||
|
const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null)
|
||||||
|
const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined
|
||||||
|
// If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed
|
||||||
|
// from the loaded page and labelled as such — rather than disappearing.
|
||||||
|
const pageTotals: SalesInvoiceListTotals | null = sales
|
||||||
|
? {
|
||||||
|
count: salesPagination?.totalItems ?? sales.length,
|
||||||
|
netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0),
|
||||||
|
paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0),
|
||||||
|
balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0),
|
||||||
|
unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length,
|
||||||
|
overdueCount: sales.filter(isOverdue).length,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
const pageOnly = totals === null
|
||||||
|
const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
salesApi
|
||||||
|
.invoiceTotals({
|
||||||
|
status: "Posted",
|
||||||
|
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||||
|
from: salesFrom,
|
||||||
|
to: salesTo,
|
||||||
|
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||||
|
q: salesQuery || undefined,
|
||||||
|
})
|
||||||
|
.then((data) => setTotalsResult({ key: totalsKey, data }))
|
||||||
|
// Cards are a summary — a failure blanks them (the table reports its own errors).
|
||||||
|
.catch(() => setTotalsResult({ key: totalsKey, data: null }))
|
||||||
|
}, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery])
|
||||||
|
|
||||||
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
|
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
|
||||||
function reloadSales() {
|
function reloadSales() {
|
||||||
setSelected(new Map())
|
setSelected(new Map())
|
||||||
@@ -198,12 +197,6 @@ export default function SalesDayEndPage() {
|
|||||||
setSelected(new Map())
|
setSelected(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeCashier(userId: number | null) {
|
|
||||||
setCashierUserId(userId)
|
|
||||||
setSalesCashier(null)
|
|
||||||
resetSalesView()
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSalesCashier(value: number | "All") {
|
function changeSalesCashier(value: number | "All") {
|
||||||
setSalesCashier(value)
|
setSalesCashier(value)
|
||||||
resetSalesView()
|
resetSalesView()
|
||||||
@@ -224,23 +217,6 @@ export default function SalesDayEndPage() {
|
|||||||
resetSalesView()
|
resetSalesView()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClose() {
|
|
||||||
if (!cashierUserId) return
|
|
||||||
setClosing(true)
|
|
||||||
try {
|
|
||||||
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
|
|
||||||
setReport(result)
|
|
||||||
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
|
|
||||||
loadPreview()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not close the day", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setClosing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
|
|
||||||
|
|
||||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||||
|
|
||||||
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
|
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
|
||||||
@@ -262,220 +238,28 @@ export default function SalesDayEndPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4 sm:gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3 sm:items-center">
|
||||||
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }), "shrink-0")}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
|
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Approved Invoices</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-sm text-muted-foreground sm:text-base">
|
||||||
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
|
Approved sales invoices — view them and take payments, one at a time or several of one customer together.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
|
<InvoiceTotalsCards
|
||||||
<div className="flex flex-col gap-2">
|
totals={cardTotals}
|
||||||
<Label className="text-base">Cashier</Label>
|
pageOnly={pageOnly}
|
||||||
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
|
scope={[
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
|
||||||
<SelectValue placeholder="Select cashier" />
|
salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "",
|
||||||
</SelectTrigger>
|
paymentFilters.find((p) => p.value === salesPayment)?.label ?? "",
|
||||||
<SelectContent>
|
].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")}
|
||||||
{cashiers.map((c) => (
|
|
||||||
<SelectItem key={c.userId} value={c.userId} className="text-base">
|
|
||||||
{c.displayName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Business date</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={businessDate}
|
|
||||||
max={todayIso()}
|
|
||||||
onChange={(e) => setBusinessDate(e.target.value)}
|
|
||||||
className="h-12 text-base"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewLoading && <Skeleton className="h-40 w-full" />}
|
|
||||||
|
|
||||||
{previewError && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{previewError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!previewLoading && preview && !report && (
|
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
|
||||||
{preview.alreadyClosed ? (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-success/30 bg-success/5 p-4 text-base text-success">
|
|
||||||
<CheckCircle2 className="size-5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.</p>
|
|
||||||
<p className="text-sm text-success/80">{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
|
||||||
<AlertTriangle className="size-5 shrink-0" />
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="font-semibold">Still Draft — post or cancel these before closing.</p>
|
|
||||||
<ul className="list-inside list-disc text-sm">
|
|
||||||
{preview.draftSlipsBlockingClose.map((s) => (
|
|
||||||
<li key={`slip-${s.salesSlipId}`}>
|
|
||||||
{s.slipNo} — {s.grandTotal.toFixed(2)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{preview.draftBundleSalesBlockingClose.map((b) => (
|
|
||||||
<li key={`bundle-${b.bundleSaleId}`}>
|
|
||||||
{b.bundleNo} — {b.grandTotal.toFixed(2)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-base text-muted-foreground">
|
|
||||||
{preview.slipCount === 0 && preview.bundleCount === 0
|
|
||||||
? preview.invoices.invoiceCount > 0
|
|
||||||
? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).`
|
|
||||||
: "No sales slips or bundle sales for this cashier on this date."
|
|
||||||
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<Stat label="Subtotal" value={preview.subtotal} />
|
|
||||||
<Stat label="Discount" value={preview.discountTotal} />
|
|
||||||
<Stat label="Tax" value={preview.taxTotal} />
|
|
||||||
<Stat label="Grand total" value={preview.grandTotal} emphasize />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Stat label="Paid (cash)" value={preview.paidTotal} />
|
|
||||||
<Stat label="On account (balance)" value={preview.balanceTotal} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InvoiceSummaryPanel invoices={preview.invoices} />
|
|
||||||
|
|
||||||
{!preview.alreadyClosed && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={closing || preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 || !cashierUserId}
|
|
||||||
>
|
|
||||||
<Lock className="size-5" />
|
|
||||||
{closing ? "Closing…" : "Close day"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{preview.alreadyClosed && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="lg" variant="outline" onClick={handleClose} disabled={closing}>
|
|
||||||
<ReceiptText className="size-5" />
|
|
||||||
{closing ? "Loading…" : "View full report"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report && (
|
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
|
||||||
<div className="flex flex-col gap-1 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">{report.docNo}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{report.glJournalNo && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
GL journal <span className="font-medium text-foreground">{report.glJournalNo}</span> posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
|
|
||||||
<Stat label="Slips" value={report.slipCount} isCount />
|
|
||||||
<Stat label="Bundles" value={report.bundleCount} isCount />
|
|
||||||
<Stat label="Subtotal" value={report.subtotal} />
|
|
||||||
<Stat label="Discount" value={report.discountTotal} />
|
|
||||||
<Stat label="Tax" value={report.taxTotal} />
|
|
||||||
<Stat label="Grand total" value={report.grandTotal} emphasize />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Stat label="Paid (cash)" value={report.paidTotal} />
|
|
||||||
<Stat label="On account (balance)" value={report.balanceTotal} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
|
|
||||||
{report.itemBreakdown.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Mobile: stacked rows. */}
|
|
||||||
<div className="flex flex-col gap-1.5 md:hidden">
|
|
||||||
{report.itemBreakdown.map((line) => (
|
|
||||||
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
|
|
||||||
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
|
|
||||||
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
|
|
||||||
{line.qty} × {line.revenue.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
|
|
||||||
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
|
|
||||||
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{report.itemBreakdown.map((line) => (
|
|
||||||
<TableRow key={line.itemId}>
|
|
||||||
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
|
|
||||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
|
|
||||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{report.slipNumbers.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Slips included</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{report.slipNumbers.join(", ")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.bundleNumbers.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Bundle sales included</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InvoiceSummaryPanel invoices={report.invoices} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SalesTable
|
<SalesTable
|
||||||
rows={sales}
|
rows={sales}
|
||||||
@@ -634,24 +418,28 @@ function SalesTable({
|
|||||||
const visible = rows ?? []
|
const visible = rows ?? []
|
||||||
const filtered = paymentFilter !== "All" || search.trim().length > 0
|
const filtered = paymentFilter !== "All" || search.trim().length > 0
|
||||||
|
|
||||||
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
|
|
||||||
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
|
|
||||||
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
<div className={cn(PANEL, "flex flex-col gap-4")}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
|
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
|
||||||
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
|
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
{/* Phones: search on top, cashier full-width, then date + payment side by side. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-4">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search invoice or customer"
|
||||||
|
className="col-span-2 h-11 text-base sm:col-span-1 sm:h-10 sm:text-sm lg:order-last"
|
||||||
|
/>
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
<Select<number | "All" | null>
|
<Select<number | "All" | null>
|
||||||
value={cashierFilter}
|
value={cashierFilter}
|
||||||
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
|
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
|
||||||
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
|
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
|
<SelectTrigger className="h-11! w-full text-sm sm:h-10!" aria-label="Cashier">
|
||||||
<SelectValue placeholder="Cashier" />
|
<SelectValue placeholder="Cashier" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -661,12 +449,13 @@ function SalesTable({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
<Select<SalesDateRange>
|
<Select<SalesDateRange>
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
|
onValueChange={(v) => onDateRangeChange(v ?? "All")}
|
||||||
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
|
<SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Date range">
|
||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -681,7 +470,7 @@ function SalesTable({
|
|||||||
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
|
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
|
||||||
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
|
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
|
<SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Payment status">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -690,12 +479,6 @@ function SalesTable({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
placeholder="Search invoice or customer"
|
|
||||||
className="h-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
|
||||||
@@ -709,17 +492,18 @@ function SalesTable({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
// Sticky on phones so "Pay selected" stays reachable while ticking rows further down.
|
||||||
|
<div className="sticky bottom-0 z-10 flex flex-col gap-2 rounded-lg border bg-card px-4 py-3 shadow-lg ring-1 ring-primary/20 sm:static sm:flex-row sm:items-center sm:justify-between sm:bg-primary/5 sm:shadow-none sm:ring-0">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||||
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
|
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
|
<Button type="button" variant="outline" size="sm" className="h-10 sm:h-8" onClick={onClearSelection}>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" onClick={onPaySelected}>
|
<Button type="button" size="sm" className="h-10 sm:h-8" onClick={onPaySelected}>
|
||||||
<CircleDollarSign className="size-4" />
|
<CircleDollarSign className="size-4" />
|
||||||
Pay selected
|
Pay selected
|
||||||
</Button>
|
</Button>
|
||||||
@@ -729,14 +513,22 @@ function SalesTable({
|
|||||||
|
|
||||||
{!error && rows !== null && visible.length > 0 && (
|
{!error && rows !== null && visible.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: stacked rows. */}
|
{/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so
|
||||||
<div className="flex flex-col gap-1.5 md:hidden">
|
nothing is squeezed. Tapping the details opens the invoice. */}
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:hidden">
|
||||||
{visible.map((row) => (
|
{visible.map((row) => (
|
||||||
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
|
<div
|
||||||
{renderSelectBox(row)}
|
key={row.salesInvoiceId}
|
||||||
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-lg border p-3 text-sm",
|
||||||
|
selected.has(row.salesInvoiceId) && "border-primary/50 bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="pt-0.5">{renderSelectBox(row)}</div>
|
||||||
|
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-start justify-between gap-2 text-left">
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate font-medium">{row.invoiceNo}</span>
|
<span className="block truncate font-semibold">{row.invoiceNo}</span>
|
||||||
<span className="block truncate text-muted-foreground">
|
<span className="block truncate text-muted-foreground">
|
||||||
{row.customerSnapshotName}
|
{row.customerSnapshotName}
|
||||||
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
|
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
|
||||||
@@ -748,29 +540,39 @@ function SalesTable({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
|
||||||
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
|
|
||||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between gap-2 border-t pt-2">
|
||||||
|
<div className="grid grid-cols-3 gap-x-3 text-xs">
|
||||||
|
<span className="text-muted-foreground">Net</span>
|
||||||
|
<span className="text-muted-foreground">Paid</span>
|
||||||
|
<span className="text-muted-foreground">Due</span>
|
||||||
|
<span className="font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
|
||||||
|
<span className="font-mono tabular-nums">{row.totals.paidAmount.toFixed(2)}</span>
|
||||||
|
<span className={cn("font-mono tabular-nums", row.totals.balanceAmount > 0 && "font-semibold text-destructive")}>
|
||||||
|
{row.totals.balanceAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{renderActions(row)}
|
{renderActions(row)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
{/* Desktop (lg+): table. Date and Paid only appear from xl, where there is room for every column. */}
|
||||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
<div className="hidden overflow-x-auto rounded-xl border lg:block">
|
||||||
<Table className="text-base">
|
<Table className="text-sm xl:text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||||
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
|
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
<TableHead className="hidden h-12 px-3 text-sm xl:table-cell">Date</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
|
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
|
<TableHead className="hidden h-12 px-3 text-right text-sm xl:table-cell">Paid</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
|
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
@@ -790,16 +592,16 @@ function SalesTable({
|
|||||||
{renderSelectBox(row)}
|
{renderSelectBox(row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
<TableCell className="max-w-48 truncate px-3 py-3.5" title={row.customerSnapshotName}>{row.customerSnapshotName}</TableCell>
|
||||||
{showCashier && (
|
{showCashier && (
|
||||||
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="hidden px-3 py-3.5 xl:table-cell">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
<TableCell className="hidden px-3 py-3.5 text-right tabular-nums xl:table-cell">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-3.5 text-right tabular-nums",
|
"px-3 py-3.5 text-right tabular-nums",
|
||||||
@@ -833,52 +635,61 @@ function SalesTable({
|
|||||||
onPageSizeChange={onPageSizeChange}
|
onPageSizeChange={onPageSizeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invoices counts every match; the amounts are for the rows on this page. */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
|
|
||||||
<Stat label="Net (this page)" value={netTotal} emphasize />
|
|
||||||
<Stat label="Paid (this page)" value={paidTotal} />
|
|
||||||
<Stat label="Remaining (this page)" value={balanceTotal} />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have
|
/** Shared card shell — tighter padding on phones, roomier from `sm` up. */
|
||||||
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros
|
const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5"
|
||||||
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
|
|
||||||
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
|
|
||||||
if (invoices.invoiceCount === 0) return null
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/20 p-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Sales invoices (not part of this close)</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Invoices post their own ledger entry when confirmed — shown here for visibility only.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
||||||
<Stat label="Invoices" value={invoices.invoiceCount} isCount />
|
|
||||||
<Stat label="Subtotal" value={invoices.invoiceSubtotal} />
|
|
||||||
<Stat label="Discount" value={invoices.invoiceDiscountTotal} />
|
|
||||||
<Stat label="Tax" value={invoices.invoiceTaxTotal} />
|
|
||||||
<Stat label="Grand total" value={invoices.invoiceGrandTotal} emphasize />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{invoices.invoiceNumbers.join(", ")}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
|
/**
|
||||||
|
* Top-of-page totals for whatever the dropdowns/search currently select — across every matching
|
||||||
|
* invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available).
|
||||||
|
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
|
||||||
|
*/
|
||||||
|
function InvoiceTotalsCards({
|
||||||
|
totals, scope, pageOnly,
|
||||||
|
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
|
||||||
|
if (totals === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}>
|
<Skeleton key={i} className="h-24 rounded-2xl" />
|
||||||
{isCount ? value : value.toFixed(2)}
|
))}
|
||||||
</span>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (totals === null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Totals for: {scope}
|
||||||
|
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} />
|
||||||
|
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
|
||||||
|
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" />
|
||||||
|
<StatCard
|
||||||
|
label="Balance due"
|
||||||
|
value={formatAmount(totals.balanceTotal)}
|
||||||
|
icon={Hourglass}
|
||||||
|
hint="Still to collect"
|
||||||
|
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
|
||||||
|
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Overdue"
|
||||||
|
value={totals.overdueCount}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
hint="Past due date, not fully paid"
|
||||||
|
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
|
||||||
|
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile"
|
|||||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
@@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
{(line.serials ?? []).length > 0 && (
|
{(line.serials ?? []).length > 0 && (
|
||||||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
// From – To runs, not one entry per unit (full list on hover).
|
||||||
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")}
|
<div
|
||||||
|
className="mt-0.5 font-mono text-xs break-all text-muted-foreground"
|
||||||
|
title={(line.serials ?? []).map((s) => s.serialNo).join(", ")}
|
||||||
|
>
|
||||||
|
S/N {formatSerialRanges(toSerialRanges((line.serials ?? []).map((s) => s.serialNo)), 3)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ export default function SalesInvoicesPage() {
|
|||||||
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
|
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
// Includes the cash customer so its (walk-in) invoices can be filtered too.
|
||||||
|
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ const sections = [
|
|||||||
icon: PackageX,
|
icon: PackageX,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Day End",
|
title: "Approved Invoices",
|
||||||
description: "Close a cashier's day and post it to the ledger.",
|
description: "Approved sales invoices — view them and take payments.",
|
||||||
href: "/dashboard/sales/day-end",
|
href: "/dashboard/sales/day-end",
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
|
import { Pencil, Plus, Power, Search, ShieldCheck, X } from "lucide-react"
|
||||||
|
|
||||||
import { rolesApi } from "@/lib/api/roles"
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Role } from "@/types/rbac"
|
import { Role } from "@/types/rbac"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -72,14 +71,15 @@ export default function RolesPage() {
|
|||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
async function handleDelete(role: Role) {
|
async function toggleStatus(role: Role) {
|
||||||
|
const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active"
|
||||||
setActionPendingId(role.roleId)
|
setActionPendingId(role.roleId)
|
||||||
try {
|
try {
|
||||||
await rolesApi.remove(role.roleId)
|
await rolesApi.updateStatus(role.roleId, next)
|
||||||
toast.success("Role deleted", `${role.code} has been removed.`)
|
toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete role", errorMessage(err))
|
toast.error("Could not update role status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setActionPendingId(null)
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
@@ -236,29 +236,26 @@ export default function RolesPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
className={cn(
|
||||||
aria-label={`Delete ${r.code}`}
|
r.status === "Active"
|
||||||
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
? "text-destructive hover:bg-destructive/10"
|
||||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
: "text-success hover:bg-success/10"
|
||||||
/>
|
)}
|
||||||
|
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
|
||||||
|
title={
|
||||||
|
r.isSystemRole
|
||||||
|
? "System roles can't be deactivated"
|
||||||
|
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
|
||||||
}
|
}
|
||||||
|
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||||
|
onClick={() => toggleStatus(r)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Power className="size-4" />
|
||||||
</AlertDialogTrigger>
|
</Button>
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${r.code}?`}
|
|
||||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(r)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,29 +344,26 @@ export default function RolesPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
className={cn(
|
||||||
aria-label={`Delete ${r.code}`}
|
r.status === "Active"
|
||||||
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
? "text-destructive hover:bg-destructive/10"
|
||||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
: "text-success hover:bg-success/10"
|
||||||
/>
|
)}
|
||||||
|
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
|
||||||
|
title={
|
||||||
|
r.isSystemRole
|
||||||
|
? "System roles can't be deactivated"
|
||||||
|
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
|
||||||
}
|
}
|
||||||
|
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||||
|
onClick={() => toggleStatus(r)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Power className="size-4" />
|
||||||
</AlertDialogTrigger>
|
</Button>
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${r.code}?`}
|
|
||||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(r)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react"
|
import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
import { auditLogsApi } from "@/lib/api/audit-logs"
|
import { auditLogsApi } from "@/lib/api/audit-logs"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
@@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit"
|
|||||||
import { ManagedUser } from "@/types/users"
|
import { ManagedUser } from "@/types/users"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -36,6 +39,15 @@ export default function UserDetailPage() {
|
|||||||
|
|
||||||
const [statusPending, setStatusPending] = useState(false)
|
const [statusPending, setStatusPending] = useState(false)
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [displayName, setDisplayName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [mobileNumber, setMobileNumber] = useState("")
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const [sendingResetLink, setSendingResetLink] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(userId)) return
|
if (!Number.isFinite(userId)) return
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
@@ -85,7 +97,10 @@ export default function UserDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
|
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
|
||||||
setUser(updated)
|
setUser(updated)
|
||||||
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated")
|
toast.success(
|
||||||
|
nextStatus === "Active" ? "User activated" : "User deactivated",
|
||||||
|
nextStatus === "Inactive" ? "They can no longer sign in." : undefined
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,6 +108,54 @@ export default function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!user) return
|
||||||
|
setDisplayName(user.displayName)
|
||||||
|
setEmail(user.email ?? "")
|
||||||
|
setMobileNumber("")
|
||||||
|
setEditErrors({})
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!user) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!displayName.trim()) nextErrors.displayName = "Display name is required"
|
||||||
|
if (!email.trim()) nextErrors.email = "Email is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await usersApi.update(user.userId, {
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
mobileNumber: mobileNumber.trim() || null,
|
||||||
|
})
|
||||||
|
setUser(updated)
|
||||||
|
setEditOpen(false)
|
||||||
|
toast.success("User updated", `${updated.displayName} — changes saved to both this app and the login system.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update user", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendResetLink() {
|
||||||
|
if (!user) return
|
||||||
|
setSendingResetLink(true)
|
||||||
|
try {
|
||||||
|
const result = await usersApi.sendResetLink(user.userId)
|
||||||
|
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
|
||||||
|
toast.success("Reset link sent", `Emailed to ${user.email}. Expires at ${expiry}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not send reset link", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSendingResetLink(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -112,6 +175,15 @@ export default function UserDetailPage() {
|
|||||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={openEdit}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleSendResetLink} disabled={sendingResetLink || !user.email}>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{sendingResetLink ? "Sending…" : "Send reset link"}
|
||||||
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||||
{user.status === "Active" ? "Active" : "Inactive"}
|
{user.status === "Active" ? "Active" : "Inactive"}
|
||||||
@@ -124,6 +196,7 @@ export default function UserDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
@@ -264,6 +337,39 @@ export default function UserDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit {user.username}</DialogTitle>
|
||||||
|
<DialogDescription>Updates both this app's record and the login system's identity.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!editErrors.displayName}>
|
||||||
|
<FieldLabel htmlFor="user-display-name">Display name</FieldLabel>
|
||||||
|
<Input id="user-display-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} aria-invalid={!!editErrors.displayName} />
|
||||||
|
<FieldError errors={[editErrors.displayName ? { message: editErrors.displayName } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.email}>
|
||||||
|
<FieldLabel htmlFor="user-email">Email</FieldLabel>
|
||||||
|
<Input id="user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!editErrors.email} />
|
||||||
|
<FieldError errors={[editErrors.email ? { message: editErrors.email } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="user-mobile">Mobile number (optional)</FieldLabel>
|
||||||
|
<Input id="user-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} placeholder="Leave blank to keep unchanged" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
|
import { KeyRound, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
|
||||||
|
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -45,6 +45,7 @@ export default function UsersPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
|
||||||
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||||
|
|
||||||
@@ -105,6 +106,19 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSendResetLink(u: ManagedUser) {
|
||||||
|
setResetLinkPendingId(u.userId)
|
||||||
|
try {
|
||||||
|
const result = await usersApi.sendResetLink(u.userId)
|
||||||
|
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
|
||||||
|
toast.success("Reset link sent", `Emailed to ${u.email}. Expires at ${expiry}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not send reset link", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setResetLinkPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -234,14 +248,6 @@ export default function UsersPage() {
|
|||||||
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
|
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
|
||||||
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
|
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
|
||||||
<div className="flex items-center gap-1" data-no-row-nav>
|
<div className="flex items-center gap-1" data-no-row-nav>
|
||||||
<Link
|
|
||||||
href={`/dashboard/settings/users/${u.userId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View ${u.username}`}
|
|
||||||
title={`View ${u.username}`}
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/settings/users/${u.userId}`}
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
@@ -250,6 +256,17 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Send reset link to ${u.username}`}
|
||||||
|
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
|
||||||
|
disabled={resetLinkPendingId === u.userId || !u.email}
|
||||||
|
onClick={() => handleSendResetLink(u)}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -324,14 +341,6 @@ export default function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Link
|
|
||||||
href={`/dashboard/settings/users/${u.userId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View ${u.username}`}
|
|
||||||
title={`View ${u.username}`}
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/settings/users/${u.userId}`}
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
@@ -340,6 +349,17 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Send reset link to ${u.username}`}
|
||||||
|
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
|
||||||
|
disabled={resetLinkPendingId === u.userId || !u.email}
|
||||||
|
onClick={() => handleSendResetLink(u)}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
|
|||||||
|
|
||||||
/** Friendly names for the document-type prefixes a serial movement references. */
|
/** Friendly names for the document-type prefixes a serial movement references. */
|
||||||
const DOC_LABEL: Record<string, string> = {
|
const DOC_LABEL: Record<string, string> = {
|
||||||
GRN: "Goods receipt",
|
GRN: "Goods received",
|
||||||
SI: "Sales invoice",
|
SI: "Sales invoice",
|
||||||
SSL: "Sales slip",
|
SSL: "Sales slip",
|
||||||
BND: "Bundle sale",
|
BND: "Bundle sale",
|
||||||
|
|||||||
+7
-19
@@ -14,6 +14,7 @@ import { Vendor } from "@/types/master-data"
|
|||||||
import { GrnSummary } from "@/types/grn"
|
import { GrnSummary } from "@/types/grn"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -27,21 +28,6 @@ import BarChart from "@/components/ui/bar-chart"
|
|||||||
|
|
||||||
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
|
||||||
/** Small tinted stat chip for the profile header — value + label + icon. */
|
|
||||||
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Icon className="size-4.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorDetailPage() {
|
export default function VendorDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const vendorId = Number(params.id)
|
const vendorId = Number(params.id)
|
||||||
@@ -293,11 +279,13 @@ export default function VendorDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
{/* Fixed-share columns so the three cards stay compact on wide screens instead of each taking a third. */}
|
||||||
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
|
<StatCard icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
|
||||||
<StatChip
|
<StatCard icon={PackageCheck} label="Goods received (GRNs)" value={grns === null ? "…" : String(grns.length)} />
|
||||||
|
<StatCard
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
|
className="col-span-2 sm:col-span-1"
|
||||||
label="Last delivery"
|
label="Last delivery"
|
||||||
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
|
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
|
||||||
|
|
||||||
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
|
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
|
||||||
* so an empty/punctuation-only name still yields a usable base. */
|
* so an empty/punctuation-only name still yields a usable base. */
|
||||||
@@ -153,13 +154,18 @@ export default function WarehousesPage() {
|
|||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
||||||
|
const phoneProblem = phoneError(phone)
|
||||||
|
if (phoneProblem) nextErrors.phone = phoneProblem
|
||||||
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not create warehouse", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
|
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId })
|
||||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||||
closeCreate()
|
closeCreate()
|
||||||
load()
|
load()
|
||||||
@@ -187,13 +193,18 @@ export default function WarehousesPage() {
|
|||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!editCode.trim()) nextErrors.code = "Code is required"
|
if (!editCode.trim()) nextErrors.code = "Code is required"
|
||||||
if (!editName.trim()) nextErrors.name = "Warehouse name is required"
|
if (!editName.trim()) nextErrors.name = "Warehouse name is required"
|
||||||
|
const phoneProblem = phoneError(editPhone)
|
||||||
|
if (phoneProblem) nextErrors.phone = phoneProblem
|
||||||
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
|
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
|
||||||
setEditErrors(nextErrors)
|
setEditErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not update warehouse", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setEditSubmitting(true)
|
setEditSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId })
|
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId })
|
||||||
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
|
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
|
||||||
setEditTarget(null)
|
setEditTarget(null)
|
||||||
load()
|
load()
|
||||||
@@ -262,9 +273,20 @@ export default function WarehousesPage() {
|
|||||||
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
|
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
|
||||||
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
|
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field data-invalid={!!errors.phone}>
|
||||||
<FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel>
|
<FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel>
|
||||||
<Input id="wh-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 …" />
|
<Input
|
||||||
|
id="wh-phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
maxLength={20}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => { setPhone(limitPhoneInput(e.target.value)); setErrors(dropError("phone")) }}
|
||||||
|
onBlur={() => { const p = phoneError(phone); if (p) setErrors((prev) => ({ ...prev, phone: p })) }}
|
||||||
|
placeholder="0771234567 or +94771234567"
|
||||||
|
aria-invalid={!!errors.phone}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.phone ? { message: errors.phone } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
{warehouseBranchingEnabled && (
|
{warehouseBranchingEnabled && (
|
||||||
<Field data-invalid={!!errors.branchId}>
|
<Field data-invalid={!!errors.branchId}>
|
||||||
@@ -556,9 +578,20 @@ export default function WarehousesPage() {
|
|||||||
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
|
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
|
||||||
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
|
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field data-invalid={!!editErrors.phone}>
|
||||||
<FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel>
|
<FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel>
|
||||||
<Input id="wh-edit-phone" value={editPhone} onChange={(e) => setEditPhone(e.target.value)} placeholder="+94 …" />
|
<Input
|
||||||
|
id="wh-edit-phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
maxLength={20}
|
||||||
|
value={editPhone}
|
||||||
|
onChange={(e) => { setEditPhone(limitPhoneInput(e.target.value)); setEditErrors(dropError("phone")) }}
|
||||||
|
onBlur={() => { const p = phoneError(editPhone); if (p) setEditErrors((prev) => ({ ...prev, phone: p })) }}
|
||||||
|
placeholder="0771234567 or +94771234567"
|
||||||
|
aria-invalid={!!editErrors.phone}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.phone ? { message: editErrors.phone } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
{warehouseBranchingEnabled && (
|
{warehouseBranchingEnabled && (
|
||||||
<Field data-invalid={!!editErrors.branchId}>
|
<Field data-invalid={!!editErrors.branchId}>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { AuthShell } from "@/components/auth/AuthShell"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { authApi } from "@/lib/api/auth"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Check, Eye, EyeOff } from "lucide-react"
|
||||||
|
|
||||||
|
// Destination of the 15-minute reset-link email (ERP_Auth_Service's RecoveryManagerService.
|
||||||
|
// ForgotPassword, ResetLink branch): `{FrontendUrl}/reset-password?token=...`. Distinct from
|
||||||
|
// /login/forgot/reset, which is the OTP-based self-service flow reached by typing an email in
|
||||||
|
// — this page only ever arrives via that emailed link's token.
|
||||||
|
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{met ? <Check className="size-4" /> : <span>○</span>}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordContent() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const token = searchParams.get("token")
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirm, setConfirm] = useState("")
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const meetsLength = password.length >= 8 && password.length <= 20
|
||||||
|
const hasUpper = /[A-Z]/.test(password)
|
||||||
|
const hasNumber = /[0-9]/.test(password)
|
||||||
|
const hasSpecial = /[^A-Za-z0-9]/.test(password)
|
||||||
|
const passwordsMatch = password === confirm && password.length > 0
|
||||||
|
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold text-foreground">Link invalid</h1>
|
||||||
|
<p className="mt-3 text-base text-muted-foreground">
|
||||||
|
This reset link is missing its token. Request a new one from the sign-in page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<Link href="/login/forgot" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
|
||||||
|
Request a new link
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!allValid || !token) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
await authApi.resetPasswordWithToken({ resetToken: token, newPassword: password, confirmPassword: confirm })
|
||||||
|
setShowSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
|
||||||
|
<p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form noValidate className="mt-10 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{submitError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{submitError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="h-11 rounded-lg px-4 pr-11 text-base"
|
||||||
|
placeholder="Enter your new password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((s) => !s)}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground">Confirm Password</label>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type={showConfirm ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="h-11 rounded-lg px-4 pr-11 text-base"
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
aria-invalid={!passwordsMatch && confirm.length > 0}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((s) => !s)}
|
||||||
|
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!passwordsMatch && confirm.length > 0 && (
|
||||||
|
<p className="mt-2 text-sm font-medium text-destructive">Passwords don't match.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
|
||||||
|
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
|
||||||
|
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
|
||||||
|
<PasswordRequirement met={hasNumber} label="At least 1 number" />
|
||||||
|
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
|
||||||
|
{submitting ? "Changing password…" : "Change password"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
|
||||||
|
<DialogContent className="w-full max-w-lg sm:max-w-lg">
|
||||||
|
<DialogHeader className="text-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
|
||||||
|
<Check className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold">Password changed successfully</DialogTitle>
|
||||||
|
<DialogDescription className="mt-2">Your password has been updated. You can now sign in with your new password.</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<Button onClick={() => router.push("/login")} className="mx-auto mt-6 h-12 w-4/5 rounded-full text-base font-semibold">
|
||||||
|
Return to sign in
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<AuthShell><Skeleton className="h-96 w-full" /></AuthShell>}>
|
||||||
|
<ResetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,7 +46,6 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
Scale,
|
Scale,
|
||||||
ScanLine,
|
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -62,12 +61,8 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { approvalsApi } from "@/lib/api/approvals"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
|
||||||
|
|
||||||
const SERIALS_HREF = "/dashboard/stock/serials"
|
|
||||||
|
|
||||||
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||||
@@ -109,8 +104,6 @@ const navItems: {
|
|||||||
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
|
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
|
|
||||||
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
|
|
||||||
{
|
{
|
||||||
title: "Procurement",
|
title: "Procurement",
|
||||||
code: "procurement",
|
code: "procurement",
|
||||||
@@ -159,9 +152,6 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||||
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||||
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
|
|
||||||
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
|
|
||||||
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
|
|
||||||
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
||||||
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||||
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||||
@@ -250,7 +240,6 @@ function SidebarContent({
|
|||||||
onClose,
|
onClose,
|
||||||
pathname,
|
pathname,
|
||||||
isMobile,
|
isMobile,
|
||||||
approvalCount,
|
|
||||||
}: {
|
}: {
|
||||||
items: typeof navItems
|
items: typeof navItems
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
@@ -258,8 +247,6 @@ function SidebarContent({
|
|||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
pathname: string
|
pathname: string
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
/** Requests the user can approve — the badge on Approvals. */
|
|
||||||
approvalCount: number
|
|
||||||
}) {
|
}) {
|
||||||
const iconOnly = !isMobile && collapsed
|
const iconOnly = !isMobile && collapsed
|
||||||
|
|
||||||
@@ -381,11 +368,6 @@ function SidebarContent({
|
|||||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||||
/>
|
/>
|
||||||
{!iconOnly && <span className="flex-1">{item.title}</span>}
|
{!iconOnly && <span className="flex-1">{item.title}</span>}
|
||||||
{item.code === "approvals" && approvalCount > 0 && (
|
|
||||||
<span className="ml-auto rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums">
|
|
||||||
{approvalCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -453,8 +435,6 @@ export function AppSidebar() {
|
|||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const { navCodes, loading } = useAuth()
|
const { navCodes, loading } = useAuth()
|
||||||
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
|
|
||||||
const { serialNumbersEnabled } = useTrackingSettings()
|
|
||||||
|
|
||||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
@@ -468,16 +448,8 @@ export function AppSidebar() {
|
|||||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||||
// anything server-side.
|
// anything server-side.
|
||||||
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry).
|
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
|
||||||
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
|
|
||||||
|
|
||||||
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
|
|
||||||
// someone has most likely just approved or submitted something.
|
|
||||||
const [approvalCount, setApprovalCount] = useState(0)
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return
|
|
||||||
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
|
|
||||||
}, [loading, pathname])
|
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
@@ -492,7 +464,7 @@ export function AppSidebar() {
|
|||||||
children: (bypassCodes.has(item.code)
|
children: (bypassCodes.has(item.code)
|
||||||
? item.children
|
? item.children
|
||||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||||
)?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
@@ -517,7 +489,6 @@ export function AppSidebar() {
|
|||||||
onCollapse={() => setCollapsed((v) => !v)}
|
onCollapse={() => setCollapsed((v) => !v)}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
approvalCount={approvalCount}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -554,7 +525,6 @@ export function AppSidebar() {
|
|||||||
onClose={() => setMobileOpen(false)}
|
onClose={() => setMobileOpen(false)}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
approvalCount={approvalCount}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
|||||||
vendors: "Vendors",
|
vendors: "Vendors",
|
||||||
warehouse: "Warehouses",
|
warehouse: "Warehouses",
|
||||||
receiving: "Receiving",
|
receiving: "Receiving",
|
||||||
grn: "Goods Receipt Notes",
|
grn: "Goods Received Notes",
|
||||||
stock: "Stock",
|
stock: "Stock",
|
||||||
enquiry: "Enquiry",
|
enquiry: "Enquiry",
|
||||||
ledger: "Ledger",
|
ledger: "Ledger",
|
||||||
@@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record<string, string> = {
|
|||||||
"purchase-invoices": "Purchase Invoices",
|
"purchase-invoices": "Purchase Invoices",
|
||||||
"purchase-returns": "Purchase Returns",
|
"purchase-returns": "Purchase Returns",
|
||||||
production: "Production",
|
production: "Production",
|
||||||
|
hrm: "HRM",
|
||||||
templates: "Templates",
|
templates: "Templates",
|
||||||
runs: "Runs",
|
runs: "Runs",
|
||||||
invoices: "Sales",
|
invoices: "Sales",
|
||||||
|
"day-end": "Approved Invoices",
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(segment: string): string {
|
function labelFor(segment: string): string {
|
||||||
@@ -51,6 +53,17 @@ function labelFor(segment: string): string {
|
|||||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the
|
||||||
|
* same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.)
|
||||||
|
*/
|
||||||
|
const GROUP_LANDING: Record<string, string> = {
|
||||||
|
"/dashboard/production": "/dashboard/production/runs",
|
||||||
|
"/dashboard/receiving": "/dashboard/receiving/grn",
|
||||||
|
"/dashboard/sales/bundles": "/dashboard/sales/bundles/templates",
|
||||||
|
"/dashboard/help/tickets": "/dashboard/help",
|
||||||
|
}
|
||||||
|
|
||||||
const linkClass =
|
const linkClass =
|
||||||
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||||
|
|
||||||
@@ -61,10 +74,10 @@ export function Breadcrumbs() {
|
|||||||
// Nothing to show above the dashboard root itself.
|
// Nothing to show above the dashboard root itself.
|
||||||
if (segments.length <= 1) return null
|
if (segments.length <= 1) return null
|
||||||
|
|
||||||
const crumbs = segments.map((segment, i) => ({
|
const crumbs = segments.map((segment, i) => {
|
||||||
href: "/" + segments.slice(0, i + 1).join("/"),
|
const path = "/" + segments.slice(0, i + 1).join("/")
|
||||||
label: labelFor(segment),
|
return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) }
|
||||||
}))
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb className="mb-4 print:hidden">
|
<Breadcrumb className="mb-4 print:hidden">
|
||||||
@@ -72,7 +85,7 @@ export function Breadcrumbs() {
|
|||||||
{crumbs.map((crumb, i) => {
|
{crumbs.map((crumb, i) => {
|
||||||
const isLast = i === crumbs.length - 1
|
const isLast = i === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<Fragment key={crumb.href}>
|
<Fragment key={crumb.key}>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||||
|
|||||||
@@ -108,10 +108,12 @@ function titleFromPath(pathname: string) {
|
|||||||
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
|
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
|
||||||
if (pathname === "/dashboard/sales/warranties") return "Warranties"
|
if (pathname === "/dashboard/sales/warranties") return "Warranties"
|
||||||
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
|
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
|
||||||
|
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
|
||||||
|
if (pathname === "/dashboard/hrm") return "HRM"
|
||||||
|
|
||||||
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
|
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
|
||||||
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
|
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note"
|
||||||
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
|
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note"
|
||||||
|
|
||||||
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
|
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
|
||||||
|
|
||||||
@@ -336,7 +338,8 @@ export function Header() {
|
|||||||
<Building2 className="size-3.5" />
|
<Building2 className="size-3.5" />
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
{/* Open below the pill (not over it), left edges lined up; at least 14rem wide, or the pill's width if that is wider. */}
|
||||||
|
<SelectContent alignItemWithTrigger={false} side="bottom" align="start" sideOffset={6} className="w-auto min-w-[max(var(--anchor-width),14rem)] p-1">
|
||||||
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
|
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
<SelectItem key={b.branchId} value={String(b.branchId)}>
|
<SelectItem key={b.branchId} value={String(b.branchId)}>
|
||||||
|
|||||||
@@ -425,10 +425,10 @@ export function AddTitleDialog({
|
|||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={saving}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
|
||||||
{saving ? "Adding…" : "Add title"}
|
{saving ? "Adding…" : "Add title"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
|
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||||
|
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
|
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
|
||||||
Pay the full balance or change the amount to pay part of it now.{" "}
|
Pay the full balance or change the amount to pay part of it now.{" "}
|
||||||
@@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{accountsError && (
|
{accountsError && (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
<div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
|
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
|
||||||
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
|
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
|
||||||
Retry
|
Retry
|
||||||
@@ -220,11 +223,13 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
|
|||||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
|
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||||
|
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||||
@@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
|||||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { validateBrandName } from "@/lib/validations/master-data"
|
||||||
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a brand from a popup — used by the Brand tab and inline from New Item. Same
|
||||||
|
* validation and endpoint as before; the caller receives the created row.
|
||||||
|
*/
|
||||||
|
export function AddBrandDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCreated,
|
||||||
|
description = "The new brand is selected for this item and offered on other items too.",
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: (brand: Brand) => void
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function close(next: boolean) {
|
||||||
|
if (submitting) return
|
||||||
|
if (!next) {
|
||||||
|
setName("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const invalid = validateBrandName(name).name
|
||||||
|
if (invalid) {
|
||||||
|
setError(invalid)
|
||||||
|
toast.error("Could not create brand", invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { data: brand } = await brandsApi.create({ name: name.trim() })
|
||||||
|
toast.success("Brand created", brand.name)
|
||||||
|
onCreated(brand)
|
||||||
|
setName("")
|
||||||
|
setError(null)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not create brand", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={close}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add brand</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="new-brand-name" className="text-base" required>
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-brand-name"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Bosch"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
className="h-12 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
|
||||||
|
{submitting ? "Saving…" : "Add Brand"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { validateUomName } from "@/lib/validations/master-data"
|
||||||
|
import { Uom } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a UOM without leaving the current form (e.g. New Item). Same validation and
|
||||||
|
* endpoint as the UOM tab; the caller receives the created row to add and select it.
|
||||||
|
*/
|
||||||
|
export function AddUomDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCreated,
|
||||||
|
description = "The new UOM is selected for this item and offered on other items too.",
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: (uom: Uom) => void
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function close(next: boolean) {
|
||||||
|
if (submitting) return
|
||||||
|
if (!next) {
|
||||||
|
setName("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const invalid = validateUomName(name).name
|
||||||
|
if (invalid) {
|
||||||
|
setError(invalid)
|
||||||
|
toast.error("Could not create UOM", invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const uom = await uomsApi.create({ name: name.trim() })
|
||||||
|
toast.success("UOM created", uom.name)
|
||||||
|
onCreated(uom)
|
||||||
|
setName("")
|
||||||
|
setError(null)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(fieldErrors(err)?.name ?? errorMessage(err))
|
||||||
|
toast.error("Could not create UOM", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={close}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add unit of measure</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="new-uom-name" className="text-base" required>
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-uom-name"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Box-12"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
className="h-12 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
|
||||||
|
{submitting ? "Saving…" : "Add UOM"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { validateBrandName } from "@/lib/validations/master-data"
|
import { validateBrandName } from "@/lib/validations/master-data"
|
||||||
import { Brand } from "@/types/master-data"
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,9 +26,7 @@ export function BrandPanel() {
|
|||||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
@@ -41,34 +40,14 @@ export function BrandPanel() {
|
|||||||
brandsApi
|
brandsApi
|
||||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||||
.then((res) => setBrands(res.items))
|
.then((res) => setBrands(res.items))
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not load brands", errorMessage(err))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
const nextErrors = validateBrandName(name)
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) {
|
|
||||||
toast.error("Could not create brand", Object.values(nextErrors)[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const result = await brandsApi.create({ name })
|
|
||||||
toast.success("Brand created", result.data.name)
|
|
||||||
setName("")
|
|
||||||
setErrors({})
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
setErrors({ name: errorMessage(err) })
|
|
||||||
toast.error("Could not create brand", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||||
async function handleToggleActive(brand: Brand) {
|
async function handleToggleActive(brand: Brand) {
|
||||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||||
@@ -119,25 +98,18 @@ export function BrandPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex justify-end">
|
||||||
<Input
|
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setName(e.target.value)
|
|
||||||
setErrors({})
|
|
||||||
}}
|
|
||||||
placeholder="New brand name, e.g. Bosch"
|
|
||||||
className="h-12 flex-1 text-base"
|
|
||||||
aria-label="New brand name"
|
|
||||||
aria-invalid={!!errors.name}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
||||||
/>
|
|
||||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
Add Brand
|
Add Brand
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
<AddBrandDialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
onCreated={load}
|
||||||
|
description="Brands are offered on every item."
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import { useEffect, useState } from "react"
|
|||||||
import { Plus, Ruler } from "lucide-react"
|
import { Plus, Ruler } from "lucide-react"
|
||||||
|
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateUomName } from "@/lib/validations/master-data"
|
|
||||||
import { Uom } from "@/types/master-data"
|
import { Uom } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { AddUomDialog } from "@/components/products/AddUomDialog"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
@@ -22,16 +21,17 @@ export function UomPanel() {
|
|||||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
uomsApi
|
uomsApi
|
||||||
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||||
.then((res) => setUoms(res.items))
|
.then((res) => setUoms(res.items))
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
toast.error("Could not load UOMs", errorMessage(err))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
@@ -50,51 +50,20 @@ export function UomPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
const nextErrors = validateUomName(name)
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) {
|
|
||||||
toast.error("Could not create UOM", Object.values(nextErrors)[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const uom = await uomsApi.create({ name })
|
|
||||||
toast.success("UOM created", uom.name)
|
|
||||||
setName("")
|
|
||||||
setErrors({})
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
const fe = fieldErrors(err)
|
|
||||||
if (fe?.name) setErrors({ name: fe.name })
|
|
||||||
toast.error("Could not create UOM", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex justify-end">
|
||||||
<Input
|
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setName(e.target.value)
|
|
||||||
setErrors({})
|
|
||||||
}}
|
|
||||||
placeholder="New UOM name, e.g. Box-12"
|
|
||||||
className="h-12 flex-1 text-base"
|
|
||||||
aria-label="New UOM name"
|
|
||||||
aria-invalid={!!errors.name}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
||||||
/>
|
|
||||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
Add UOM
|
Add UOM
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
<AddUomDialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
onCreated={load}
|
||||||
|
description="Units of measure are offered on every item."
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serial / warranty numbers shown as From – To ranges instead of one row per unit. A single
|
||||||
|
* run reads inline ("10 serial numbers: SN-001 – SN-010"); several runs expand behind a toggle.
|
||||||
|
*/
|
||||||
|
export function SerialRanges({ label, values }: { label: string; values: string[] }) {
|
||||||
|
const ranges = toSerialRanges(values)
|
||||||
|
if (ranges.length === 0) return null
|
||||||
|
const total = ranges.reduce((n, r) => n + r.count, 0)
|
||||||
|
const heading = `${total} ${label}${total === 1 ? "" : "s"}`
|
||||||
|
|
||||||
|
if (ranges.length <= 2) {
|
||||||
|
return (
|
||||||
|
<p className="break-all">
|
||||||
|
{heading}: <span className="font-mono font-medium text-foreground">{formatSerialRanges(ranges)}</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer select-none">
|
||||||
|
{heading} in {ranges.length} ranges: <span className="font-mono text-foreground">{formatSerialRanges(ranges)}</span>
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-1 flex max-h-48 flex-col gap-0.5 overflow-y-auto font-mono">
|
||||||
|
{ranges.map((r) => (
|
||||||
|
<li key={r.from} className="flex justify-between gap-4">
|
||||||
|
<span className="break-all text-foreground">{r.count === 1 ? r.from : `${r.from} – ${r.to}`}</span>
|
||||||
|
<span className="shrink-0 tabular-nums">×{r.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,41 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
|
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Line } from "react-chartjs-2"
|
import { Line } from "react-chartjs-2"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||||
import { DayEndReportDay } from "@/types/sales"
|
import { DayEndReportDay } from "@/types/sales"
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||||
|
|
||||||
|
/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */
|
||||||
|
const MOBILE_POINTS = 5
|
||||||
|
|
||||||
|
/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the
|
||||||
|
* legend box is made taller after it measures itself — the extra height sits below its items. */
|
||||||
|
const LEGEND_GAP_PX = 16
|
||||||
|
const legendGap: Plugin<"line"> = {
|
||||||
|
id: "legendGap",
|
||||||
|
beforeInit(chart) {
|
||||||
|
const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined
|
||||||
|
if (!legend) return
|
||||||
|
const fit = legend.fit.bind(legend)
|
||||||
|
legend.fit = function () {
|
||||||
|
fit()
|
||||||
|
this.height += LEGEND_GAP_PX
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */
|
||||||
|
const LEGEND_TEXT_GAP = String.fromCharCode(0x2002)
|
||||||
|
|
||||||
|
/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */
|
||||||
|
const compactAmount = (v: number) =>
|
||||||
|
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v)
|
||||||
|
|
||||||
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
|
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
|
||||||
const SERIES = {
|
const SERIES = {
|
||||||
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
|
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
|
||||||
@@ -17,9 +44,12 @@ const SERIES = {
|
|||||||
|
|
||||||
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
|
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
|
||||||
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
|
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
|
||||||
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
|
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
|
||||||
|
const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays
|
||||||
|
const trimmed = days.length < allDays.length
|
||||||
const labels = days.map((d) => formatReportDate(d.date))
|
const labels = days.map((d) => formatReportDate(d.date))
|
||||||
|
|
||||||
const line = (label: string, data: number[], color: string, width: number) => ({
|
const line = (label: string, data: number[], color: string, width: number) => ({
|
||||||
@@ -27,16 +57,25 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
|||||||
data,
|
data,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: width,
|
borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width,
|
||||||
pointRadius: days.length > 45 ? 0 : 3,
|
pointRadius: days.length > 45 ? 0 : 3,
|
||||||
pointHoverRadius: 5,
|
pointHoverRadius: 5,
|
||||||
tension: 0.25,
|
tension: 0.25,
|
||||||
fill: false,
|
fill: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tickFont = { size: isMobile ? 10 : 12 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-72 w-full">
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
{trimmed && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Showing the last {days.length} days — the table below lists every day.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="h-64 w-full sm:h-72">
|
||||||
<Line
|
<Line
|
||||||
|
plugins={[legendGap]}
|
||||||
data={{
|
data={{
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -51,7 +90,20 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: "index", intersect: false },
|
interaction: { mode: "index", intersect: false },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
|
// Legend on top (start-aligned on desktop, centred on phones) with small labels on phones.
|
||||||
|
legend: {
|
||||||
|
position: "top",
|
||||||
|
align: isMobile ? "center" : "start",
|
||||||
|
labels: {
|
||||||
|
color: c.text,
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: isMobile ? 6 : 8,
|
||||||
|
font: { size: isMobile ? 11 : 12 },
|
||||||
|
// Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it.
|
||||||
|
generateLabels: (chart) =>
|
||||||
|
ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })),
|
||||||
|
},
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => {
|
label: (ctx) => {
|
||||||
@@ -66,11 +118,23 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
|
x: {
|
||||||
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
|
grid: { display: false },
|
||||||
|
ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: c.grid },
|
||||||
|
ticks: {
|
||||||
|
color: c.text,
|
||||||
|
font: tickFont,
|
||||||
|
maxTicksLimit: isMobile ? 5 : 8,
|
||||||
|
callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ function SerialPicker({
|
|||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
disabled={toAdd.length === 0}
|
disabled={toAdd.length === 0}
|
||||||
onClick={selectAll}
|
onClick={selectAll}
|
||||||
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
|
title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined}
|
||||||
>
|
>
|
||||||
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
|
Select all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-60 overflow-y-auto rounded-md border">
|
<div className="max-h-60 overflow-y-auto rounded-md border">
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ interface PaymentLinesEditorProps {
|
|||||||
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
|
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
|
||||||
const paid = paymentLinesTotal(lines)
|
const paid = paymentLinesTotal(lines)
|
||||||
const remaining = round2(balance - paid)
|
const remaining = round2(balance - paid)
|
||||||
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
|
// Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up
|
||||||
|
// it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them.
|
||||||
|
const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]"
|
||||||
|
const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden"
|
||||||
|
|
||||||
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
||||||
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||||
@@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className={`grid ${cols} gap-2 text-sm font-medium`}>
|
<div className={`hidden gap-2 text-sm font-medium sm:grid ${cols}`}>
|
||||||
<span>Payment type</span>
|
<span>Payment type</span>
|
||||||
{methodOptions && <span>Method</span>}
|
{methodOptions && <span>Method</span>}
|
||||||
<span>Paid amount</span>
|
<span>Paid amount</span>
|
||||||
@@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lines.map((line, idx) => (
|
{lines.map((line, idx) => (
|
||||||
<div key={line.key} className={`grid ${cols} items-start gap-2`}>
|
<div
|
||||||
<div>
|
key={line.key}
|
||||||
|
className={`grid grid-cols-2 items-start gap-2 rounded-lg border p-3 sm:rounded-none sm:border-0 sm:p-0 ${cols}`}
|
||||||
|
>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
|
||||||
|
<span className={mobileLabel}>Payment type {lines.length > 1 ? idx + 1 : ""}</span>
|
||||||
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
|
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="w-full text-base"
|
className="h-11 w-full text-base sm:h-9"
|
||||||
aria-label={`Payment type, line ${idx + 1}`}
|
aria-label={`Payment type, line ${idx + 1}`}
|
||||||
aria-invalid={!!errors[`account-${line.key}`]}
|
aria-invalid={!!errors[`account-${line.key}`]}
|
||||||
>
|
>
|
||||||
@@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
{methodOptions && (
|
{methodOptions && (
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className={mobileLabel}>Method</span>
|
||||||
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
|
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
|
||||||
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}>
|
<SelectTrigger className="h-11 w-full text-base sm:h-9" aria-label={`Payment method, line ${idx + 1}`}>
|
||||||
<SelectValue placeholder="Method" />
|
<SelectValue placeholder="Method" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className={`flex flex-col gap-1 sm:col-span-1 ${methodOptions ? "" : "col-span-2"}`}>
|
||||||
|
<span className={mobileLabel}>Paid amount</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
value={line.amount}
|
value={line.amount}
|
||||||
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
||||||
aria-label={`Paid amount, line ${idx + 1}`}
|
aria-label={`Paid amount, line ${idx + 1}`}
|
||||||
aria-invalid={!!errors[`amount-${line.key}`]}
|
aria-invalid={!!errors[`amount-${line.key}`]}
|
||||||
className="text-right tabular-nums"
|
className="h-11 text-right text-base tabular-nums sm:h-9"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="col-span-2 flex items-center justify-between gap-1 sm:col-span-1 sm:justify-start">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -148,12 +158,13 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
||||||
disabled={lines.length === 1}
|
disabled={lines.length === 1}
|
||||||
aria-label={`Remove line ${idx + 1}`}
|
aria-label={`Remove line ${idx + 1}`}
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
||||||
{idx === lines.length - 1 && (
|
{idx === lines.length - 1 && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
|
<Button type="button" variant="outline" size="sm" onClick={addLine} className="h-10 whitespace-nowrap sm:mt-1 sm:h-8">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add line
|
Add line
|
||||||
</Button>
|
</Button>
|
||||||
@@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
||||||
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
|
<div className="grid w-full grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm sm:ml-auto sm:max-w-64">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">Total</span>
|
||||||
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
||||||
<span className="text-muted-foreground">Paid amount</span>
|
<span className="text-muted-foreground">Paid amount</span>
|
||||||
|
|||||||
@@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle className="pr-8 leading-snug">
|
||||||
Pay {ordered.length} invoices — {customerName}
|
Pay {ordered.length} invoices — {customerName}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||||
|
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||||
|
|
||||||
{draftCount > 0 && (
|
{draftCount > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
||||||
@@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto rounded-lg border">
|
<div className="max-h-48 shrink-0 overflow-auto rounded-lg border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 text-muted-foreground">
|
<thead className="bg-muted/50 text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Date</th>
|
<th className="hidden px-3 py-2 text-left font-medium sm:table-cell">Date</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
|||||||
<tbody>
|
<tbody>
|
||||||
{allocation.map(({ inv, applied }) => (
|
{allocation.map(({ inv, applied }) => (
|
||||||
<tr key={inv.salesInvoiceId} className="border-t">
|
<tr key={inv.salesInvoiceId} className="border-t">
|
||||||
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
|
<td className="px-3 py-2 font-medium whitespace-nowrap">{inv.invoiceNo}</td>
|
||||||
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
<td className="hidden px-3 py-2 sm:table-cell">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
|||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||||
|
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||||
@@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
|||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
||||||
|
// Mirrors Branch.DefaultCode (backend) — the deployment's seeded branch, which
|
||||||
|
// BranchService blocks deactivating or deleting (every branch-scoped entity needs
|
||||||
|
// at least one branch to exist).
|
||||||
|
const DEFAULT_BRANCH_CODE = "BR-000"
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
const MOBILE_PAGE_SIZE_STEP = 10
|
||||||
@@ -329,12 +333,16 @@ export function BranchMasterPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
item.status === "Active"
|
item.status === "Active"
|
||||||
? "text-destructive hover:bg-destructive/10"
|
? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
: "text-success hover:bg-success/10"
|
: "text-success hover:bg-success/10"
|
||||||
)}
|
)}
|
||||||
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
title={
|
||||||
disabled={actionPendingId === item.branchId}
|
item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
|
||||||
|
? "The default branch cannot be deactivated"
|
||||||
|
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
|
||||||
|
}
|
||||||
|
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
|
||||||
onClick={() => toggleStatus(item)}
|
onClick={() => toggleStatus(item)}
|
||||||
>
|
>
|
||||||
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
@@ -345,10 +353,10 @@ export function BranchMasterPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
aria-label={`Delete ${item.name}`}
|
aria-label={`Delete ${item.name}`}
|
||||||
title={`Delete ${item.name}`}
|
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
|
||||||
disabled={deletingId === item.branchId}
|
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -433,12 +441,16 @@ export function BranchMasterPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
item.status === "Active"
|
item.status === "Active"
|
||||||
? "text-destructive hover:bg-destructive/10"
|
? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
: "text-success hover:bg-success/10"
|
: "text-success hover:bg-success/10"
|
||||||
)}
|
)}
|
||||||
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
title={
|
||||||
disabled={actionPendingId === item.branchId}
|
item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
|
||||||
|
? "The default branch cannot be deactivated"
|
||||||
|
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
|
||||||
|
}
|
||||||
|
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
|
||||||
onClick={() => toggleStatus(item)}
|
onClick={() => toggleStatus(item)}
|
||||||
>
|
>
|
||||||
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
@@ -449,10 +461,10 @@ export function BranchMasterPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
aria-label={`Delete ${item.name}`}
|
aria-label={`Delete ${item.name}`}
|
||||||
title={`Delete ${item.name}`}
|
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
|
||||||
disabled={deletingId === item.branchId}
|
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ function AlertDialogContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay data-no-row-nav />
|
||||||
<AlertDialogPrimitive.Popup
|
<AlertDialogPrimitive.Popup
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
|
data-no-row-nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
|
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
|
||||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ function DialogContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay data-no-row-nav />
|
||||||
<DialogPrimitive.Popup
|
<DialogPrimitive.Popup
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
data-no-row-nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
|||||||
? tiles.map((tile) => (
|
? tiles.map((tile) => (
|
||||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||||
))
|
))
|
||||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
|
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,63 +46,86 @@ export interface StatCardProps {
|
|||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
|
/** Small muted line under the value, e.g. "All time" or "3 with balance due". */
|
||||||
|
hint?: string
|
||||||
delta?: number
|
delta?: number
|
||||||
deltaLabel?: string
|
deltaLabel?: string
|
||||||
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
||||||
invert?: boolean
|
invert?: boolean
|
||||||
trend?: number[]
|
trend?: number[]
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Extra classes for the value, e.g. a success/destructive colour. */
|
||||||
|
valueClassName?: string
|
||||||
|
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
|
||||||
|
iconClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's KPI tile: a tinted icon square on the left, then label / bold value / optional
|
||||||
|
* hint stacked beside it. Every list page's stat row uses this so they all read the same.
|
||||||
|
*/
|
||||||
export function StatCard({
|
export function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
hint,
|
||||||
delta,
|
delta,
|
||||||
deltaLabel,
|
deltaLabel,
|
||||||
invert = false,
|
invert = false,
|
||||||
trend,
|
trend,
|
||||||
className,
|
className,
|
||||||
|
valueClassName,
|
||||||
|
iconClassName,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
const hasDelta = typeof delta === "number" && delta !== 0
|
const hasDelta = typeof delta === "number" && delta !== 0
|
||||||
const isPositive = hasDelta && delta > 0
|
const isPositive = hasDelta && delta > 0
|
||||||
const isGood = hasDelta && (invert ? !isPositive : isPositive)
|
const isGood = hasDelta && (invert ? !isPositive : isPositive)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// A size container: the card reads its *own* width, so it lays out the same whether a page puts
|
||||||
|
// it in a 2-up phone grid or a wide desktop row.
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
value keeps the full width instead of being cut off. */}
|
||||||
|
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div
|
||||||
<Icon className="size-3.5 text-primary" />
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
|
||||||
|
iconClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
||||||
<div className="flex items-end justify-between gap-2">
|
<div className="flex items-end justify-between gap-2">
|
||||||
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
|
||||||
|
{formatValue(value)}
|
||||||
|
</p>
|
||||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDelta && (
|
{hasDelta ? (
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span
|
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
|
||||||
className={cn(
|
|
||||||
"font-semibold",
|
|
||||||
isGood ? "text-success" : "text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPositive ? "+" : "-"}
|
{isPositive ? "+" : "-"}
|
||||||
{Math.abs(delta).toFixed(1)}%
|
{Math.abs(delta).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages an already-loaded list in the browser, producing the same `PaginationMeta` shape the
|
||||||
|
* server-paged lists use so it plugs straight into `PaginationFooter`.
|
||||||
|
*
|
||||||
|
* `resetKey` (e.g. the selected date) sends the list back to page 1 when it changes. It's a key
|
||||||
|
* rather than the array itself because callers often pass `data ?? []`, a new array every render.
|
||||||
|
*/
|
||||||
|
export function useClientPagination<T>(rows: T[], resetKey: unknown, initialPageSize = 10) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSizeState] = useState(initialPageSize)
|
||||||
|
const [prevKey, setPrevKey] = useState(resetKey)
|
||||||
|
|
||||||
|
// Reset during render (React's "adjust state when a prop changes" pattern) — no effect needed.
|
||||||
|
if (!Object.is(prevKey, resetKey)) {
|
||||||
|
setPrevKey(resetKey)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalItems = rows.length
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||||
|
// Clamped, so shrinking the list (or a bigger page size) never leaves an empty page showing.
|
||||||
|
const current = Math.min(page, totalPages)
|
||||||
|
const pageRows = rows.slice((current - 1) * pageSize, current * pageSize)
|
||||||
|
const pagination: PaginationMeta = { page: current, pageSize, totalItems, totalPages }
|
||||||
|
|
||||||
|
function setPageSize(size: number) {
|
||||||
|
setPageSizeState(size)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageRows, pagination, setPage, setPageSize }
|
||||||
|
}
|
||||||
@@ -35,4 +35,9 @@ export const authApi = {
|
|||||||
refresh(): Promise<AuthSession> {
|
refresh(): Promise<AuthSession> {
|
||||||
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
|
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Consumes a 15-minute reset-link token (from the emailed link's `?token=`) and sets a new password. */
|
||||||
|
resetPasswordWithToken(request: { resetToken: string; newPassword: string; confirmPassword: string }): Promise<void> {
|
||||||
|
return apiRequest<void>("/auth/recovery/reset-password-token", { method: "POST", body: request })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,22 @@ export const bankAccountsApi = {
|
|||||||
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
|
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today
|
/** Rename only — the default cash account allows this too, just not delete/deactivate. */
|
||||||
|
updateCash(cashAccountId: number, accountName: string): Promise<CreateCashOrBankAccountResponse> {
|
||||||
|
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}`, { method: "PUT", body: { accountName } })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Throws (surfaced via errorMessage) if deactivating the default cash account. */
|
||||||
|
setCashStatus(cashAccountId: number, isActive: boolean): Promise<CreateCashOrBankAccountResponse> {
|
||||||
|
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}/status`, { method: "PATCH", body: { isActive } })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Throws if this is the default cash account. */
|
||||||
|
deleteCash(cashAccountId: number): Promise<void> {
|
||||||
|
return glRequest<void>(`/cash-accounts/${cashAccountId}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
|
|
||||||
|
// No get()/update() for Bank: GL exposes no GET/PUT by id for bank_account today
|
||||||
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
|
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SalesInvoiceStatus,
|
SalesInvoiceStatus,
|
||||||
SalesInvoicePayment,
|
SalesInvoicePayment,
|
||||||
SalesInvoicePostingCheck,
|
SalesInvoicePostingCheck,
|
||||||
|
SalesInvoiceListTotals,
|
||||||
SalesInvoiceSummary,
|
SalesInvoiceSummary,
|
||||||
SalesListFilterParams,
|
SalesListFilterParams,
|
||||||
SalesReportDefinition,
|
SalesReportDefinition,
|
||||||
@@ -56,6 +57,11 @@ export const salesApi = {
|
|||||||
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
|
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Count/amount totals for the same filters as `listInvoices`, across every match (not one page). */
|
||||||
|
invoiceTotals(params: Omit<ListSalesInvoicesParams, "page" | "pageSize"> = {}): Promise<SalesInvoiceListTotals> {
|
||||||
|
return apiRequest<SalesInvoiceListTotals>(`/sales-invoices/totals${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
|
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
|
||||||
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
|
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
|
||||||
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
|
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ManagedUser,
|
ManagedUser,
|
||||||
PurgeAuthHexIdentityRequest,
|
PurgeAuthHexIdentityRequest,
|
||||||
PurgeAuthHexIdentityResult,
|
PurgeAuthHexIdentityResult,
|
||||||
|
SendResetLinkResponse,
|
||||||
|
UpdateManagedUserRequest,
|
||||||
UpdateUserRoleRequest,
|
UpdateUserRoleRequest,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
UserTypeOption,
|
UserTypeOption,
|
||||||
@@ -38,6 +40,16 @@ export const usersApi = {
|
|||||||
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Edits DisplayName/Email/MobileNumber on both backends (local shadow row + AuthHex identity). */
|
||||||
|
update(userId: number, request: UpdateManagedUserRequest): Promise<ManagedUser> {
|
||||||
|
return apiRequest<ManagedUser>(`/users/${userId}`, { method: "PUT", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Emails the user a 15-minute password-reset link via AuthHex's forgot-password flow. */
|
||||||
|
sendResetLink(userId: number): Promise<SendResetLinkResponse> {
|
||||||
|
return apiRequest<SendResetLinkResponse>(`/users/${userId}/send-reset-link`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
/** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */
|
/** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */
|
||||||
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
|
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
|
||||||
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
|
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Collapses serial / warranty numbers into consecutive runs for display:
|
||||||
|
* SN-001, SN-002 … SN-050, SN-077 → "SN-001 – SN-050" (50) and "SN-077" (1).
|
||||||
|
*
|
||||||
|
* Uses the same "prefix + trailing number" reading as the GRN form's range builder, so a run
|
||||||
|
* entered there as From/adding comes back out as exactly one range here.
|
||||||
|
*/
|
||||||
|
export type SerialRange = { from: string; to: string; count: number }
|
||||||
|
|
||||||
|
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
|
||||||
|
const m = s.match(/^(.*?)(\d+)$/)
|
||||||
|
if (!m) return null
|
||||||
|
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Natural order: by prefix, then by the trailing number (so SN-9 sorts before SN-10). */
|
||||||
|
function compareSerials(a: string, b: string): number {
|
||||||
|
const pa = parseTrailingNumber(a)
|
||||||
|
const pb = parseTrailingNumber(b)
|
||||||
|
if (pa && pb && pa.prefix === pb.prefix) return pa.num - pb.num
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSerialRanges(values: string[]): SerialRange[] {
|
||||||
|
const sorted = [...new Set(values.map((v) => v.trim()).filter(Boolean))].sort(compareSerials)
|
||||||
|
const ranges: SerialRange[] = []
|
||||||
|
let run: { start: string; prefix: string; width: number; last: number; lastText: string; count: number } | null = null
|
||||||
|
|
||||||
|
for (const value of sorted) {
|
||||||
|
const parsed = parseTrailingNumber(value)
|
||||||
|
// Consecutive = same prefix, next number, written with the run's zero-padding.
|
||||||
|
const continues =
|
||||||
|
run !== null &&
|
||||||
|
parsed !== null &&
|
||||||
|
parsed.prefix === run.prefix &&
|
||||||
|
parsed.num === run.last + 1 &&
|
||||||
|
value === `${run.prefix}${String(parsed.num).padStart(run.width, "0")}`
|
||||||
|
|
||||||
|
if (continues && run && parsed) {
|
||||||
|
run.last = parsed.num
|
||||||
|
run.lastText = value
|
||||||
|
run.count += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
|
||||||
|
run = parsed
|
||||||
|
? { start: value, prefix: parsed.prefix, width: parsed.width, last: parsed.num, lastText: value, count: 1 }
|
||||||
|
: { start: value, prefix: `\u0000${value}`, width: 0, last: Number.NaN, lastText: value, count: 1 }
|
||||||
|
}
|
||||||
|
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line summary, e.g. "SN-001 – SN-050" or "SN-001 – SN-050, SN-077 +2 more". */
|
||||||
|
export function formatSerialRanges(ranges: SerialRange[], max = 2): string {
|
||||||
|
const label = (r: SerialRange) => (r.count === 1 ? r.from : `${r.from} – ${r.to}`)
|
||||||
|
const shown = ranges.slice(0, max).map(label).join(", ")
|
||||||
|
return ranges.length > max ? `${shown} +${ranges.length - max} more` : shown
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the
|
||||||
|
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
|
||||||
|
*
|
||||||
|
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits
|
||||||
|
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits.
|
||||||
|
*/
|
||||||
|
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/
|
||||||
|
|
||||||
|
/** Digit count allowed after the prefix's own digits, per format. */
|
||||||
|
const LOCAL_DIGITS = 10 // 0 + 9
|
||||||
|
const INTL_DIGITS = 11 // 94 + 9
|
||||||
|
|
||||||
|
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting
|
||||||
|
* digits once the number is full, so the limit is enforced while typing rather than on save.
|
||||||
|
*/
|
||||||
|
export function limitPhoneInput(raw: string): string {
|
||||||
|
let out = ""
|
||||||
|
let digits = 0
|
||||||
|
for (const ch of raw) {
|
||||||
|
if (ch === "+") {
|
||||||
|
if (out.length === 0) out += ch // "+" only as the very first character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === " " || ch === "-") {
|
||||||
|
out += ch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!/\d/.test(ch)) continue
|
||||||
|
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS
|
||||||
|
if (digits >= max) continue
|
||||||
|
out += ch
|
||||||
|
digits += 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error message for an invalid phone, or null when valid. Blank is valid unless `required`. */
|
||||||
|
export function phoneError(value: string, { required = false }: { required?: boolean } = {}): string | null {
|
||||||
|
const phone = value.trim()
|
||||||
|
if (!phone) return required ? "Enter a phone number" : null
|
||||||
|
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
|
||||||
|
const digits = phone.replace(/\D/g, "")
|
||||||
|
if (phone.startsWith("+")) {
|
||||||
|
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
|
||||||
|
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)`
|
||||||
|
} else {
|
||||||
|
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}`
|
||||||
|
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
|
||||||
|
}
|
||||||
|
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -294,6 +294,9 @@ export interface CashAndBankAccountDto {
|
|||||||
glAccountCode: string
|
glAccountCode: string
|
||||||
currencyCode: string
|
currencyCode: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** Cash only — null for Bank rows, which carry no such protection yet. */
|
||||||
|
isActive: boolean | null
|
||||||
|
isDefault: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ export interface SalesInvoiceTotals {
|
|||||||
balanceAmount: number
|
balanceAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Totals over every invoice matching the list filters (GET /sales-invoices/totals), not one page. */
|
||||||
|
export interface SalesInvoiceListTotals {
|
||||||
|
count: number
|
||||||
|
netTotal: number
|
||||||
|
paidTotal: number
|
||||||
|
balanceTotal: number
|
||||||
|
unpaidCount: number
|
||||||
|
overdueCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SalesInvoiceSummary {
|
export interface SalesInvoiceSummary {
|
||||||
salesInvoiceId: number
|
salesInvoiceId: number
|
||||||
invoiceNo: string
|
invoiceNo: string
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ export interface UpdateUserRoleRequest {
|
|||||||
roleId: number
|
roleId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edits both backends — the local shadow row, and (when linked) the AuthHex identity itself. */
|
||||||
|
export interface UpdateManagedUserRequest {
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
mobileNumber?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResetLinkResponse {
|
||||||
|
expiresAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateUserStatusRequest {
|
export interface UpdateUserStatusRequest {
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user