Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2b92f3086 |
@@ -1,92 +0,0 @@
|
||||
name: CI/CD - ERP Core Monorepo
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- production
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
build-and-deploy-backend:
|
||||
name: Build & Deploy Backend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Restore
|
||||
run: dotnet restore Backend/ERPCore.sln
|
||||
- name: Publish
|
||||
run: |
|
||||
rm -rf /tmp/erpcore-deploy
|
||||
mkdir -p /tmp/erpcore-deploy
|
||||
dotnet publish Backend/ERPCore/ERPCore.csproj \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
-o /tmp/erpcore-deploy
|
||||
- name: Stop service
|
||||
run: systemctl stop erpcore.service
|
||||
- name: Copy to deploy folder
|
||||
run: |
|
||||
mkdir -p /home/erp/core/publish
|
||||
rm -rf /home/erp/core/publish/*
|
||||
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
|
||||
- name: Fix ownership and permissions
|
||||
run: |
|
||||
chown -R erpuser:erpuser /home/erp/core/publish
|
||||
find /home/erp/core/publish -type d -exec chmod 755 {} +
|
||||
find /home/erp/core/publish -type f -exec chmod 644 {} +
|
||||
chmod +x /home/erp/core/publish/ERPCore
|
||||
- name: Restart service
|
||||
run: systemctl restart erpcore.service
|
||||
- name: Verify
|
||||
run: |
|
||||
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
|
||||
|
||||
build-and-deploy-frontend:
|
||||
name: Build & Deploy Frontend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build --progress=plain --no-cache \
|
||||
-t erpcore-frontend:latest \
|
||||
./Frontend/erp-system
|
||||
- name: Stop & remove existing container
|
||||
run: |
|
||||
docker stop erpcore-frontend 2>/dev/null || true
|
||||
docker rm erpcore-frontend 2>/dev/null || true
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name erpcore-frontend \
|
||||
-p 6702:6702 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
|
||||
--restart unless-stopped \
|
||||
--memory="512m" \
|
||||
--cpus="1.0" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
erpcore-frontend:latest
|
||||
- name: Remove old images
|
||||
run: docker image prune -f
|
||||
- name: Verify
|
||||
run: |
|
||||
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
||||
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
|
||||
@@ -28,20 +28,14 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IAuthAltService _alt;
|
||||
private readonly IRoleService _roles;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly IUserManagementService _userManagement;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public AuthController(
|
||||
IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit,
|
||||
IUserManagementService userManagement, ICurrentUser currentUser)
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
_roles = roles;
|
||||
_audit = audit;
|
||||
_userManagement = userManagement;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -145,8 +139,6 @@ public sealed class AuthController : ControllerBase
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
|
||||
// AuthHex accepted the new password — release the first-login forced change.
|
||||
await _userManagement.ClearMustChangePasswordAsync(_currentUser.AuditUserId, ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -30,19 +30,6 @@ public sealed class ItemsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||
|
||||
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
|
||||
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
[HttpGet("scan")]
|
||||
|
||||
@@ -29,10 +29,8 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
@@ -19,9 +18,8 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -31,14 +29,4 @@ public sealed class UomsController : ApiControllerBase
|
||||
var dto = await _uoms.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{uomId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,6 @@ public class BranchConfig
|
||||
/// single-branch already).</summary>
|
||||
public bool WarehouseBranchingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>Independent sub-toggle for Purchase Orders. On: each PO belongs to one branch,
|
||||
/// non-admins only see their own branch's POs, and line warehouses must be in that branch.
|
||||
/// Off: POs form one shared pool across branches (kept on the default branch, visible to
|
||||
/// everyone, any warehouse allowed). Meaningless when <see cref="BranchingEnabled"/> is off.</summary>
|
||||
public bool PurchaseOrderBranchingEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -12,8 +10,4 @@ public class Uom
|
||||
{
|
||||
public int UomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -31,14 +31,6 @@ public class User
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set when an admin creates the account with an issued (temporary) password
|
||||
/// (<c>UserManagementService.CreateAsync</c>); surfaced via <c>GET /auth/me</c> so the
|
||||
/// frontend forces a password change on first login. Cleared by
|
||||
/// <c>POST /auth/change-password</c>.
|
||||
/// </summary>
|
||||
public bool MustChangePassword { get; set; }
|
||||
|
||||
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
|
||||
public int? RoleId { get; set; }
|
||||
public Role? Role { get; set; }
|
||||
|
||||
@@ -14,8 +14,6 @@ public class Vendor
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Terms { get; set; }
|
||||
public string? TaxReg { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string Currency { get; set; } = "LKR";
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
|
||||
/// requires a branch on Employee/Warehouse records at all.</summary>
|
||||
public sealed record BranchConfigDto(
|
||||
bool BranchingEnabled, bool WarehouseBranchingEnabled, bool PurchaseOrderBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
@@ -19,8 +18,4 @@ public sealed class UpdateBranchConfigRequest
|
||||
{
|
||||
[Required] public bool? BranchingEnabled { get; set; }
|
||||
[Required] public bool? WarehouseBranchingEnabled { get; set; }
|
||||
|
||||
/// <summary>Optional so existing callers that only know the first two flags keep working —
|
||||
/// omitted means "leave as is".</summary>
|
||||
public bool? PurchaseOrderBranchingEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed record DashboardStatsDto(
|
||||
decimal StockValuationTotal,
|
||||
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
|
||||
int PendingApprovalPurchaseOrders,
|
||||
int FullyReceivedPurchaseOrders,
|
||||
int PendingGrns,
|
||||
int OpenRequisitions,
|
||||
int PendingCounts,
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed record GrnDto(
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
|
||||
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
|
||||
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
|
||||
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
|
||||
/// computed on every keystroke.
|
||||
/// </summary>
|
||||
public sealed record ItemOverviewDto(
|
||||
int ItemId, string Sku, string Name,
|
||||
int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
EntityStatus Status,
|
||||
decimal StockQty, decimal StockValue, bool IsLowStock,
|
||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
||||
decimal SalesQtyLast30Days);
|
||||
@@ -8,12 +8,7 @@ namespace ERPCore.Dtos.Rbac;
|
||||
/// branch pickers client-side only (no server-side branch enforcement exists yet,
|
||||
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
|
||||
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
|
||||
/// entirely rather than merely restricting them by role. <see cref="MustChangePassword"/>
|
||||
/// is true for an admin-created account that hasn't replaced its issued password yet —
|
||||
/// the frontend holds the user on the change-password screen until it is false.</summary>
|
||||
/// entirely rather than merely restricting them by role.</summary>
|
||||
public sealed record MeResponseDto(
|
||||
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
|
||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
|
||||
bool MustChangePassword = false,
|
||||
bool PurchaseOrderBranchingEnabled = true,
|
||||
int? UserId = null);
|
||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Uoms;
|
||||
|
||||
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
public sealed record UomDto(int UomId, string Name);
|
||||
|
||||
public sealed class CreateUomRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateUomStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
|
||||
|
||||
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
public sealed record VendorDto(
|
||||
int VendorId, string Code, string Name, string? Terms, string? TaxReg, string? Address, string? Phone,
|
||||
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
|
||||
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
public sealed class CreateVendorRequest
|
||||
@@ -14,8 +14,6 @@ public sealed class CreateVendorRequest
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[StringLength(500)] public string? Address { get; set; }
|
||||
[StringLength(30)] public string? Phone { get; set; }
|
||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
|
||||
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
|
||||
@@ -28,8 +26,6 @@ public sealed class UpdateVendorRequest
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[StringLength(500)] public string? Address { get; set; }
|
||||
[StringLength(30)] public string? Phone { get; set; }
|
||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||
public int BranchId { get; set; }
|
||||
}
|
||||
|
||||
@@ -107,14 +107,11 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
|
||||
// ---- RecoveryManager ------------------------------------------------
|
||||
|
||||
// RecoveryManagerService returns its own { success, message, data } object, which the
|
||||
// function handler then wraps again as the envelope's `data` — so the payload sits one
|
||||
// level deeper than for other AuthHex calls (ERP_Auth_Service RecoveryManager.cs).
|
||||
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> Unwrap(await CallAsync<AuthHexEnvelope<ForgotPasswordResponse>>("recovery", "forgotPassword", request, null, ct));
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
|
||||
|
||||
public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
|
||||
@@ -152,14 +149,6 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
|
||||
// ---- Transport --------------------------------------------------------
|
||||
|
||||
/// <summary>Unwraps the inner result object that the recovery functions nest inside the envelope's data.</summary>
|
||||
private static T Unwrap<T>(AuthHexEnvelope<T>? inner)
|
||||
{
|
||||
if (inner is null || !inner.Success || inner.Data is null)
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, inner?.Message ?? "Authentication request failed.", 400);
|
||||
return inner.Data;
|
||||
}
|
||||
|
||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
|
||||
// Defaults on so the existing row keeps today's per-branch purchase orders.
|
||||
builder.Property(c => c.PurchaseOrderBranchingEnabled).IsRequired().HasDefaultValue(true);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
|
||||
|
||||
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(u => u.Name).IsUnique();
|
||||
|
||||
builder.Property(u => u.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(u => u.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(u => u.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
|
||||
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
|
||||
builder.Property(v => v.Terms).HasMaxLength(50);
|
||||
builder.Property(v => v.TaxReg).HasMaxLength(50);
|
||||
builder.Property(v => v.Address).HasMaxLength(500);
|
||||
builder.Property(v => v.Phone).HasMaxLength(30);
|
||||
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
|
||||
|
||||
builder.Property(v => v.Status)
|
||||
|
||||
@@ -195,12 +195,7 @@ public class ErpDbContext : DbContext
|
||||
modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
// Purchase orders are only scoped while the "divide purchase orders by branch" toggle is
|
||||
// on — off turns them into one shared pool every branch can see.
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive
|
||||
|| e.BranchId == _currentBranch.BranchId
|
||||
|| !Set<Domain.Entities.BranchConfig>().Any(c => c.ConfigId == Domain.Entities.BranchConfig.SingletonId
|
||||
&& c.BranchingEnabled && c.PurchaseOrderBranchingEnabled));
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
|
||||
// Npgsql requires DateTime values written to `timestamp with time zone` columns
|
||||
|
||||
-7954
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUomLifecycleColumnsLocal : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "uoms",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Status",
|
||||
table: "uoms",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Active");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAt",
|
||||
table: "uoms",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_uoms_Status",
|
||||
table: "uoms",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_uoms_Status",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAt",
|
||||
table: "uoms");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class changeMismatch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
-8749
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserMustChangePassword : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "MustChangePassword",
|
||||
table: "users",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "users",
|
||||
keyColumn: "UserId",
|
||||
keyValue: 1,
|
||||
column: "MustChangePassword",
|
||||
value: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MustChangePassword",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8757
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorAddressPhone : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Address",
|
||||
table: "vendors",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Phone",
|
||||
table: "vendors",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Address",
|
||||
table: "vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Phone",
|
||||
table: "vendors");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8762
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPurchaseOrderBranchingToggle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PurchaseOrderBranchingEnabled",
|
||||
table: "branch_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PurchaseOrderBranchingEnabled",
|
||||
table: "branch_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,11 +344,6 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("PurchaseOrderBranchingEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
@@ -6350,31 +6345,16 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UomId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("uoms", (string)null);
|
||||
});
|
||||
|
||||
@@ -6399,9 +6379,6 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6435,7 +6412,6 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
UserId = 1,
|
||||
DisplayName = "System",
|
||||
MustChangePassword = false,
|
||||
Status = "Active",
|
||||
Username = "system"
|
||||
});
|
||||
@@ -6449,10 +6425,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6476,10 +6448,6 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:6701",
|
||||
"applicationUrl": "http://localhost:5224",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7112;http://localhost:6701",
|
||||
"applicationUrl": "https://localhost:7112;http://localhost:5224",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthRecoveryService"/>
|
||||
public sealed class AuthRecoveryService : IAuthRecoveryService
|
||||
{
|
||||
/// <summary>Longer than any AuthHex recovery OTP lives; only needs to outlast the flow.</summary>
|
||||
private static readonly TimeSpan RecoveryIdentifierTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IUserManagementService _userManagement;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
|
||||
{
|
||||
_authHex = authHex;
|
||||
_userManagement = userManagement;
|
||||
_cache = cache;
|
||||
}
|
||||
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _authHex.ForgotPasswordAsync(request, ct);
|
||||
// The reset call only carries the reference number — remember who it was for, so a
|
||||
// successful reset can also release a pending first-login forced password change.
|
||||
if (!string.IsNullOrEmpty(response.ReferenceNumber))
|
||||
_cache.Set(CacheKey(response.ReferenceNumber), request.Identifier.Trim(), RecoveryIdentifierTtl);
|
||||
return response;
|
||||
}
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ForgotPasswordAsync(request, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
|
||||
|
||||
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await _authHex.ResetPasswordAsync(request, ct);
|
||||
|
||||
var key = CacheKey(request.ReferenceNumber);
|
||||
if (_cache.TryGetValue(key, out string? identifier) && !string.IsNullOrEmpty(identifier))
|
||||
{
|
||||
_cache.Remove(key);
|
||||
await _userManagement.ClearMustChangePasswordByIdentifierAsync(identifier, ct);
|
||||
}
|
||||
}
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
|
||||
|
||||
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ public sealed class BranchConfigService : IBranchConfigService
|
||||
|
||||
config.BranchingEnabled = request.BranchingEnabled!.Value;
|
||||
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
|
||||
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
|
||||
config.PurchaseOrderBranchingEnabled = poBranching;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -112,25 +110,6 @@ public sealed class BranchConfigService : IBranchConfigService
|
||||
return await ResolveDefaultBranchIdAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
|
||||
return (config?.BranchingEnabled ?? true) && (config?.PurchaseOrderBranchingEnabled ?? true);
|
||||
}
|
||||
|
||||
public async Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
|
||||
{
|
||||
if (await IsPurchaseOrderBranchingEnabledAsync(ct))
|
||||
{
|
||||
if (requestedBranchId <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
|
||||
return requestedBranchId;
|
||||
}
|
||||
|
||||
return await ResolveDefaultBranchIdAsync(ct);
|
||||
}
|
||||
|
||||
// Branching (or warehouse branching specifically) is off — ignore whatever was posted and
|
||||
// use the deployment's one default branch, creating it on first use (same placeholder
|
||||
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
|
||||
@@ -146,6 +125,5 @@ public sealed class BranchConfigService : IBranchConfigService
|
||||
return branch.BranchId;
|
||||
}
|
||||
|
||||
private static BranchConfigDto Map(BranchConfig c) => new(
|
||||
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,6 @@ public sealed class DashboardService : IDashboardService
|
||||
// not yet added) — these three stay global regardless of the selected branch.
|
||||
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
|
||||
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
|
||||
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
|
||||
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
|
||||
var openRequisitions = await _requisitions.Query().AsNoTracking()
|
||||
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
|
||||
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
|
||||
@@ -107,7 +105,7 @@ public sealed class DashboardService : IDashboardService
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
|
||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@ public interface IBranchConfigService
|
||||
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
|
||||
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when Purchase Orders are divided by branch. Never true when
|
||||
/// <see cref="IsBranchingEnabledAsync"/> is false.</summary>
|
||||
Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
|
||||
/// <see cref="IsPurchaseOrderBranchingEnabledAsync"/> — off puts every PO on the default branch.</summary>
|
||||
Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
|
||||
/// first use if it doesn't exist yet. Used as a fallback branch for document types that
|
||||
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
|
||||
|
||||
@@ -15,12 +15,6 @@ public interface IItemService
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
|
||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
|
||||
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
public interface IUomService
|
||||
{
|
||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,6 @@ public interface IUserManagementService
|
||||
/// </summary>
|
||||
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Clears the first-login forced password change once the user has set their own password.</summary>
|
||||
Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same, for a forgot-password reset — resolves the user by email or username.</summary>
|
||||
Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
@@ -36,7 +35,6 @@ public sealed class ItemService : IItemService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<StockLedger> _stockLedger;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly IProductConfigService _config;
|
||||
@@ -54,7 +52,6 @@ public sealed class ItemService : IItemService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLayer> stockLayers,
|
||||
IRepository<StockLedger> stockLedger,
|
||||
IRepository<GrnLine> grnLines,
|
||||
IRepository<Batch> batches,
|
||||
IRepository<Serial> serials,
|
||||
IProductConfigService config,
|
||||
@@ -71,7 +68,6 @@ public sealed class ItemService : IItemService
|
||||
_warehouses = warehouses;
|
||||
_stockLayers = stockLayers;
|
||||
_stockLedger = stockLedger;
|
||||
_grnLines = grnLines;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_config = config;
|
||||
@@ -112,82 +108,6 @@ public sealed class ItemService : IItemService
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(i => i.Status == status);
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
|
||||
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
|
||||
// reduced in memory rather than a "first per group" query EF can't always translate.
|
||||
var grnCandidates = await _grnLines.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
|
||||
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
|
||||
.ToListAsync(ct);
|
||||
var lastPurchaseByItem = grnCandidates
|
||||
.GroupBy(x => x.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
|
||||
|
||||
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
|
||||
// read off the stock ledger — the one place every sale channel posts uniformly.
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
|
||||
var salesByItem = await _stockLedger.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
|
||||
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
var rows = page.Select(i =>
|
||||
{
|
||||
var stock = stockByItem.GetValueOrDefault(i.ItemId);
|
||||
var isLowStock = i.ReorderSettings.Any(rs =>
|
||||
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
|
||||
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
|
||||
|
||||
return new ItemOverviewDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
|
||||
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
|
||||
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
|
||||
salesByItem.GetValueOrDefault(i.ItemId));
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
@@ -305,16 +305,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
}
|
||||
|
||||
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
|
||||
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.
|
||||
/// Skipped unless both purchase orders AND warehouses are divided by branch: with PO
|
||||
/// branching off the PO sits on the default branch, and with warehouse branching off every
|
||||
/// warehouse is parked on the default branch too (shared by all branches) — either way a
|
||||
/// branch match is meaningless and would reject every line.</summary>
|
||||
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
|
||||
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
|
||||
{
|
||||
if (!await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)) return;
|
||||
if (!await _branchConfig.IsWarehouseBranchingEnabledAsync(ct)) return;
|
||||
|
||||
var ids = warehouseIds.Distinct().ToList();
|
||||
var mismatched = await _warehouses.Query().AsNoTracking()
|
||||
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class RoleService : IRoleService
|
||||
private readonly IRepository<RolePermission> _rolePermissions;
|
||||
private readonly IRepository<Permission> _permissions;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
@@ -30,13 +29,12 @@ public sealed class RoleService : IRoleService
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_employees = employees;
|
||||
_users = users;
|
||||
_currentUser = currentUser;
|
||||
_authHex = authHex;
|
||||
_branchConfig = branchConfig;
|
||||
@@ -204,22 +202,6 @@ public sealed class RoleService : IRoleService
|
||||
}
|
||||
|
||||
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
var me = await BuildMeAsync(roleCode, ct) with
|
||||
{
|
||||
PurchaseOrderBranchingEnabled = await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)
|
||||
};
|
||||
if (!_currentUser.IsAuthenticated) return me;
|
||||
|
||||
var mustChangePassword = await _users.Query().AsNoTracking()
|
||||
.Where(u => u.UserId == _currentUser.AuditUserId)
|
||||
.Select(u => u.MustChangePassword)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// Local user id lets the frontend spot records the caller created (e.g. self-approval of a PO).
|
||||
return me with { MustChangePassword = mustChangePassword, UserId = _currentUser.AuditUserId };
|
||||
}
|
||||
|
||||
private async Task<MeResponseDto> BuildMeAsync(string? roleCode, CancellationToken ct)
|
||||
{
|
||||
// The caller's branch, via their optional Employee link — independent of role,
|
||||
// since even an Admin's own "home" branch may be worth surfacing later. Null
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -64,9 +64,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
||||
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _uoms.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(u => u.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(u => u.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
|
||||
.Select(u => new UomDto(u.UomId, u.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
|
||||
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
||||
throw new ConflictException($"A UOM named '{name}' already exists.");
|
||||
|
||||
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
var uom = new Uom { Name = name };
|
||||
await _uoms.AddAsync(uom, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var uom = await _uoms.GetByIdAsync(uomId, ct)
|
||||
?? throw new NotFoundException($"UOM {uomId} was not found.");
|
||||
|
||||
uom.Status = status;
|
||||
uom.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new UomDto(uom.UomId, uom.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,7 @@ public sealed class UserManagementService : IUserManagementService
|
||||
DisplayName = request.FullName.Trim(),
|
||||
Email = request.Email.Trim(),
|
||||
RoleId = role.RoleId,
|
||||
Status = EntityStatus.Active,
|
||||
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
|
||||
MustChangePassword = true
|
||||
Status = EntityStatus.Active
|
||||
};
|
||||
|
||||
await _users.AddAsync(user, ct);
|
||||
@@ -185,27 +183,6 @@ public sealed class UserManagementService : IUserManagementService
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.GetByIdAsync(userId, ct);
|
||||
if (user is null || !user.MustChangePassword) return;
|
||||
|
||||
user.MustChangePassword = false;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default)
|
||||
{
|
||||
var term = identifier.Trim();
|
||||
var user = await _users.Query()
|
||||
.FirstOrDefaultAsync(u => u.MustChangePassword
|
||||
&& ((u.Email != null && EF.Functions.ILike(u.Email, term)) || u.Username == term), ct);
|
||||
if (user is null) return;
|
||||
|
||||
user.MustChangePassword = false;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||
|
||||
@@ -62,8 +62,6 @@ public sealed class VendorService : IVendorService
|
||||
Name = request.Name.Trim(),
|
||||
Terms = request.Terms,
|
||||
TaxReg = request.TaxReg,
|
||||
Address = Normalize(request.Address),
|
||||
Phone = Normalize(request.Phone),
|
||||
Currency = request.Currency.Trim().ToUpperInvariant(),
|
||||
Status = EntityStatus.Active,
|
||||
BranchId = branchId,
|
||||
@@ -96,8 +94,6 @@ public sealed class VendorService : IVendorService
|
||||
vendor.Name = request.Name.Trim();
|
||||
vendor.Terms = request.Terms;
|
||||
vendor.TaxReg = request.TaxReg;
|
||||
vendor.Address = Normalize(request.Address);
|
||||
vendor.Phone = Normalize(request.Phone);
|
||||
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
|
||||
vendor.BranchId = branchId;
|
||||
vendor.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -124,8 +120,6 @@ public sealed class VendorService : IVendorService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static VendorDto Map(Vendor v) => new(
|
||||
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
||||
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:6703"
|
||||
"BaseUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
|
||||
},
|
||||
"Urls": "http://0.0.0.0:6701",
|
||||
"Auth": {
|
||||
"Issuer": "AuthHex",
|
||||
"Audience": "AuthHexClient",
|
||||
@@ -17,7 +16,7 @@
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:6703"
|
||||
"BaseUrl": "http://localhost:5602"
|
||||
},
|
||||
"FileStorage": {
|
||||
"RootPath": "App_Data/hr-documents",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
.vercel
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.local.example
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -4,4 +4,4 @@
|
||||
#
|
||||
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
|
||||
# proxy will refuse.
|
||||
BACKEND_ORIGIN=http://localhost:6701
|
||||
BACKEND_ORIGIN=http://localhost:5224
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=6702
|
||||
|
||||
COPY package.json ./
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
RUN npm prune --production && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
EXPOSE 6702
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client"
|
||||
|
||||
// Forced first-login password change: admin-created accounts land here (via the login page
|
||||
// and AuthProvider's guard) while GET /auth/me reports mustChangePassword. The server clears
|
||||
// the flag and ends the session on success, so the user signs in again with the new password.
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Check, Eye, EyeOff } from "lucide-react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { clearStoredUser } from "@/lib/auth-session"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { AuthShell } from "@/components/auth/AuthShell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
|
||||
{met ? <Check className="size-4" /> : <span>○</span>}
|
||||
</div>
|
||||
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
invalid,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
autoComplete: string
|
||||
invalid?: boolean
|
||||
}) {
|
||||
const [show, setShow] = useState(false)
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-semibold text-foreground">{label}</label>
|
||||
<div className="relative mt-2">
|
||||
<Input
|
||||
id={id}
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-11 rounded-lg px-4 pr-11 text-base"
|
||||
placeholder={placeholder}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{show ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter()
|
||||
const [current, setCurrent] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirm, setConfirm] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const meetsLength = password.length >= 8 && password.length <= 20
|
||||
const hasUpper = /[A-Z]/.test(password)
|
||||
const hasNumber = /[0-9]/.test(password)
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(password)
|
||||
const differsFromCurrent = password.length > 0 && password !== current
|
||||
const passwordsMatch = password === confirm && password.length > 0
|
||||
const allValid = current.length > 0 && meetsLength && hasUpper && hasNumber && hasSpecial && differsFromCurrent && passwordsMatch
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!allValid) return
|
||||
setSubmitError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await authApi.changePassword(current, password)
|
||||
clearStoredUser()
|
||||
router.replace("/login?passwordChanged=1")
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await authApi.logout().catch(() => {})
|
||||
clearStoredUser()
|
||||
router.replace("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold tracking-wide text-primary uppercase">First sign in</p>
|
||||
<h1 className="mt-1 text-3xl font-bold text-foreground">Set your password</h1>
|
||||
<p className="mt-3 text-base text-muted-foreground">
|
||||
Your account was created with a temporary password. Choose a new one to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
id="current"
|
||||
label="Temporary Password"
|
||||
value={current}
|
||||
onChange={setCurrent}
|
||||
placeholder="Enter the password you signed in with"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
label="New Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="Enter your new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="confirm"
|
||||
label="Confirm Password"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder="Confirm your new password"
|
||||
autoComplete="new-password"
|
||||
invalid={!passwordsMatch && confirm.length > 0}
|
||||
/>
|
||||
{!passwordsMatch && confirm.length > 0 && (
|
||||
<p className="mt-2 text-sm font-medium text-destructive">Passwords don't match.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
|
||||
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
|
||||
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
|
||||
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
|
||||
<PasswordRequirement met={hasNumber} label="At least 1 number" />
|
||||
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
|
||||
<PasswordRequirement met={differsFromCurrent} label="Different from the temporary password" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
|
||||
{submitting ? "Saving…" : "Change password"}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<button type="button" onClick={signOut} className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -12,44 +12,23 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
type AccountTypeFilter = CashBankAccountType | "Both"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size,
|
||||
// same convention as the other list pages even though this one pages client-side.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function BankAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => setPage(1), [debouncedSearch, accountType])
|
||||
|
||||
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
|
||||
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
|
||||
// There's no server pagination on this endpoint (it returns the full union in one array), so
|
||||
// page/pageSize below are applied client-side over whatever this call returns.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
|
||||
@@ -61,7 +40,6 @@ export default function BankAccountsPage() {
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -71,7 +49,7 @@ export default function BankAccountsPage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return accounts
|
||||
return accounts.filter((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
@@ -84,201 +62,116 @@ export default function BankAccountsPage() {
|
||||
(gl?.accountCode ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [accounts, debouncedSearch, glAccountsById])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
return {
|
||||
total: accounts.length,
|
||||
bank: accounts.filter((a) => a.accountType === CashBankAccountType.Bank).length,
|
||||
cash: accounts.filter((a) => a.accountType === CashBankAccountType.Cash).length,
|
||||
}
|
||||
}, [accounts])
|
||||
|
||||
const totalItems = filtered?.length ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
const pageItems = filtered?.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) ?? null
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
setShowMoreLoading(false)
|
||||
}
|
||||
}, [accounts, search, glAccountsById])
|
||||
|
||||
return (
|
||||
<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 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
{accounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{accounts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Accounts"
|
||||
value={stats.total}
|
||||
icon={Wallet}
|
||||
active={accountType === "Both"}
|
||||
onClick={() => setAccountType("Both")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Bank Accounts"
|
||||
value={stats.bank}
|
||||
icon={Banknote}
|
||||
active={accountType === CashBankAccountType.Bank}
|
||||
onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cash Accounts"
|
||||
value={stats.cash}
|
||||
icon={Wallet}
|
||||
active={accountType === CashBankAccountType.Cash}
|
||||
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, bank/type, account no. or currency…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search cash/bank accounts"
|
||||
/>
|
||||
</div>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger className="h-14! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search name, bank/type, account no. or currency…"
|
||||
advancedActive={accountType !== "Both"}
|
||||
onClearAdvanced={() => setAccountType("Both")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bank-filter-type">Type</FieldLabel>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
{!error && filtered === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems !== null && pageItems.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No accounts match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different name, bank or account number.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
|
||||
{!error && filtered !== null && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Wallet className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
|
||||
<p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Account
|
||||
</Link>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems !== null && pageItems.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pageItems.map((a) => {
|
||||
{!error && filtered !== null && filtered.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<div key={`${a.accountType}-${a.accountId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{a.accountName}</p>
|
||||
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
|
||||
</div>
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 shrink-0 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bank / Cash type</p>
|
||||
<p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Currency</p>
|
||||
<p className="font-medium text-foreground">{a.currencyCode}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">GL account</p>
|
||||
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
@@ -287,94 +180,12 @@ export default function BankAccountsPage() {
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{totalItems > pageItems.length && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,6 @@ import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
|
||||
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
|
||||
@@ -39,7 +38,6 @@ export default function ChequeBookDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const { branchingEnabled } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
@@ -101,12 +99,10 @@ export default function ChequeBookDetailPage() {
|
||||
<p className="text-sm text-muted-foreground">Bank account</p>
|
||||
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Range</p>
|
||||
<p className="text-base font-medium">
|
||||
@@ -152,7 +148,7 @@ export default function ChequeBookDetailPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
||||
</TableCell>
|
||||
|
||||
@@ -57,10 +57,7 @@ export default function NewChequeBookPage() {
|
||||
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
|
||||
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
|
||||
// still needs *some* branch id regardless of our toggle.
|
||||
// With branching off the field is hidden entirely, but GL still needs a branch id — use the
|
||||
// deployment's default branch (BR-000, the one the backend files everything under then).
|
||||
const defaultBranchId = (branches.find((b) => b.code === "BR-000") ?? branches[0])?.branchId ?? null
|
||||
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : defaultBranchId
|
||||
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
|
||||
const showBranchPicker = !lockedBranchId
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,7 +137,6 @@ export default function NewChequeBookPage() {
|
||||
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
{branchingEnabled && (
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
@@ -167,7 +163,6 @@ export default function NewChequeBookPage() {
|
||||
)}
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={!!errors.chequeBookNo}>
|
||||
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
|
||||
import { ArrowLeft, BookText, Plus } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -15,425 +15,152 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = ChequeBookStatus | "All"
|
||||
|
||||
interface StatusCounts {
|
||||
total: number
|
||||
active: number
|
||||
completed: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
|
||||
[ChequeBookStatus.Active]: "bg-success/10 text-success",
|
||||
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
|
||||
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function ChequeBooksPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [books, setBooks] = useState<ChequeBook[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => setPage(1), [status, activeBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
Promise.all([
|
||||
chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
|
||||
chequeBooksApi.list({
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
}),
|
||||
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
|
||||
])
|
||||
.then(([result, banks]) => {
|
||||
if (cancelled) return
|
||||
setBooks(result.items)
|
||||
setTotalItems(result.totalCount)
|
||||
setBankAccounts(banks)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShowMoreLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, status, activeBranchId, branchingEnabled])
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
|
||||
// out the other tiles.
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (s?: ChequeBookStatus) =>
|
||||
chequeBooksApi
|
||||
.list({ page: 1, pageSize: 1, status: s, branchId })
|
||||
.then((res) => res.totalCount)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([
|
||||
countFor(undefined),
|
||||
countFor(ChequeBookStatus.Active),
|
||||
countFor(ChequeBookStatus.Completed),
|
||||
countFor(ChequeBookStatus.Cancelled),
|
||||
]).then(([total, active, completed, cancelled]) => setStatusCounts({ total, active, completed, cancelled }))
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
|
||||
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
|
||||
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
|
||||
|
||||
// The API has no `q` param, so search narrows whatever page is currently loaded — same
|
||||
// pattern as the Sales Invoices list.
|
||||
const visibleBooks = useMemo(() => {
|
||||
if (!books) return null
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return books
|
||||
return books.filter((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
const branch = branchesById.get(b.branchId)
|
||||
return (
|
||||
b.chequeBookNo.toLowerCase().includes(q) ||
|
||||
(bank?.accountName ?? "").toLowerCase().includes(q) ||
|
||||
(branch?.name ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [books, debouncedSearch, bankAccountsById, branchesById])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
const hasFilters = debouncedSearch.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<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 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
{statusCounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{statusCounts.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
<p className="text-base text-muted-foreground">Cheque books issued from this company's own supply.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusCounts ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Cheque Books"
|
||||
value={statusCounts.total}
|
||||
icon={BookText}
|
||||
active={status === "All"}
|
||||
onClick={() => setStatus("All")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active"
|
||||
value={statusCounts.active}
|
||||
icon={CheckCircle2}
|
||||
active={status === ChequeBookStatus.Active}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed"
|
||||
value={statusCounts.completed}
|
||||
icon={BookCheck}
|
||||
active={status === ChequeBookStatus.Completed}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancelled"
|
||||
value={statusCounts.cancelled}
|
||||
icon={BookX}
|
||||
active={status === ChequeBookStatus.Cancelled}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search cheque book no., bank or branch…"
|
||||
advancedActive={status !== "All"}
|
||||
onClearAdvanced={() => setStatus("All")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
{!error && books === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks !== null && visibleBooks.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cheque books match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different cheque book no., bank or branch.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
|
||||
{!error && books !== null && books.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
|
||||
{!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
|
||||
</div>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-base text-muted-foreground">No cheque books yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleBooks.map((b) => {
|
||||
{!error && books !== null && books.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{books.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<div
|
||||
<TableRow
|
||||
key={b.chequeBookNo}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{b.startChequeNo} – {b.endChequeNo}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bank account</p>
|
||||
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Range</p>
|
||||
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Leaves</p>
|
||||
<p className="font-medium text-foreground">{b.totalLeaves}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Received</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${b.chequeBookNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{totalItems > visibleBooks.length && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
|
||||
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleBooks.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
key={b.chequeBookNo}
|
||||
className="align-top cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{b.chequeBookNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
|
||||
{branchingEnabled && (
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${b.chequeBookNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
|
||||
import { ArrowLeft, Inbox, Plus } from "lucide-react"
|
||||
|
||||
import { receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -14,27 +14,14 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
|
||||
|
||||
type StatusFilter = ReceivedChequeStatus | "All"
|
||||
|
||||
interface StatusCounts {
|
||||
total: number
|
||||
received: number
|
||||
deposited: number
|
||||
cleared: number
|
||||
returned: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
|
||||
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
|
||||
@@ -43,27 +30,12 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function ReceivedChequesPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
@@ -71,385 +43,124 @@ export default function ReceivedChequesPage() {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => setPage(1), [status, activeBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
receivedChequesApi
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setCheques(res.items)
|
||||
setTotalItems(res.totalCount)
|
||||
if (!cancelled) setCheques(res.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShowMoreLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [page, pageSize, status, activeBranchId, branchingEnabled])
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
|
||||
// out the other tiles.
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (s?: ReceivedChequeStatus) =>
|
||||
receivedChequesApi
|
||||
.list({ page: 1, pageSize: 1, status: s, branchId })
|
||||
.then((res) => res.totalCount)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([
|
||||
countFor(undefined),
|
||||
countFor(ReceivedChequeStatus.Received),
|
||||
countFor(ReceivedChequeStatus.Deposited),
|
||||
countFor(ReceivedChequeStatus.Cleared),
|
||||
countFor(ReceivedChequeStatus.Returned),
|
||||
countFor(ReceivedChequeStatus.Cancelled),
|
||||
]).then(([total, received, deposited, cleared, returned, cancelled]) =>
|
||||
setStatusCounts({ total, received, deposited, cleared, returned, cancelled })
|
||||
)
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
|
||||
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
|
||||
|
||||
// The API has no `q` param, so search narrows whatever page is currently loaded — same
|
||||
// pattern as the Sales Invoices list.
|
||||
const visibleCheques = useMemo(() => {
|
||||
if (!cheques) return null
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return cheques
|
||||
return cheques.filter((c) => {
|
||||
const branch = c.branchId ? branchesById.get(c.branchId) : null
|
||||
return (
|
||||
c.chequeNo.toLowerCase().includes(q) ||
|
||||
c.receivedFromName.toLowerCase().includes(q) ||
|
||||
(branch?.name ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [cheques, debouncedSearch, branchesById])
|
||||
|
||||
function handleUpdated(updated: ReceivedCheque) {
|
||||
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
|
||||
setSelected(updated)
|
||||
}
|
||||
|
||||
function openDialog(c: ReceivedCheque) {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
const hasFilters = debouncedSearch.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<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 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
{statusCounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{statusCounts.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusCounts ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Received"
|
||||
value={statusCounts.total}
|
||||
icon={Inbox}
|
||||
active={status === "All"}
|
||||
onClick={() => setStatus("All")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Received"
|
||||
value={statusCounts.received}
|
||||
icon={Wallet}
|
||||
active={status === ReceivedChequeStatus.Received}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Deposited"
|
||||
value={statusCounts.deposited}
|
||||
icon={Banknote}
|
||||
active={status === ReceivedChequeStatus.Deposited}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cleared"
|
||||
value={statusCounts.cleared}
|
||||
icon={CheckCircle2}
|
||||
active={status === ReceivedChequeStatus.Cleared}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Returned"
|
||||
value={statusCounts.returned}
|
||||
icon={Undo2}
|
||||
active={status === ReceivedChequeStatus.Returned}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
|
||||
advancedActive={status !== "All"}
|
||||
onClearAdvanced={() => setStatus("All")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
{!error && cheques === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques !== null && visibleCheques.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cheques match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different cheque no., name or branch.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
|
||||
{!error && cheques !== null && cheques.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Inbox className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
|
||||
{!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
|
||||
</div>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-base text-muted-foreground">No received cheques yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleCheques.map((c) => (
|
||||
<div
|
||||
{!error && cheques !== null && cheques.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cheques.map((c) => (
|
||||
<TableRow
|
||||
key={c.receivedChequeId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openDialog(c)
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") openDialog(c)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Received from</p>
|
||||
<p className="font-medium text-foreground">{c.receivedFromName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<p className="font-medium text-foreground">{c.receivedFromType}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Received</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View cheque ${c.chequeNo}`}
|
||||
title="View"
|
||||
onClick={() => openDialog(c)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{totalItems > visibleCheques.length && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleCheques.map((c) => (
|
||||
<TableRow
|
||||
key={c.receivedChequeId}
|
||||
className="align-top cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openDialog(c)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||
{branchingEnabled && (
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[c.status])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View cheque ${c.chequeNo}`}
|
||||
title="View"
|
||||
onClick={() => openDialog(c)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
|
||||
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Customer, CustomerType } from "@/types/customers"
|
||||
import { SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
|
||||
const INVOICE_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 Users; 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 CustomerDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const customerId = Number(params.id)
|
||||
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [customerCode, setCustomerCode] = useState("")
|
||||
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
|
||||
const [name, setName] = useState("")
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [addressLine1, setAddressLine1] = useState("")
|
||||
const [addressLine2, setAddressLine2] = useState("")
|
||||
const [city, setCity] = useState("")
|
||||
const [country, setCountry] = useState("")
|
||||
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
|
||||
const [creditLimit, setCreditLimit] = useState("0")
|
||||
const [creditDays, setCreditDays] = useState("0")
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
// Profile activity: this customer's recent sales invoices ("selling").
|
||||
const [invoices, setInvoices] = useState<SalesInvoiceSummary[] | null>(null)
|
||||
|
||||
function applyCustomer(data: Customer) {
|
||||
setCustomer(data)
|
||||
setCustomerCode(data.customerCode)
|
||||
setCustomerType(data.customerType)
|
||||
setName(data.name)
|
||||
setDisplayName(data.displayName ?? "")
|
||||
setPhone(data.phone ?? "")
|
||||
setEmail(data.email ?? "")
|
||||
setAddressLine1(data.addressLine1 ?? "")
|
||||
setAddressLine2(data.addressLine2 ?? "")
|
||||
setCity(data.city ?? "")
|
||||
setCountry(data.country ?? "")
|
||||
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
|
||||
setCreditLimit(String(data.creditLimit))
|
||||
setCreditDays(String(data.creditDays))
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
customersApi
|
||||
.get(customerId)
|
||||
.then(({ data, etag: tag }) => {
|
||||
applyCustomer(data)
|
||||
setEtag(tag)
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(customerId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [customerId])
|
||||
|
||||
/** Opens the edit dialog fresh from the last-loaded customer, discarding any unsaved
|
||||
* edits left over from a previous cancelled session. */
|
||||
function openEdit() {
|
||||
if (customer) applyCustomer(customer)
|
||||
setErrors({})
|
||||
setSaveError(null)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
if (customer) applyCustomer(customer)
|
||||
setEditOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(customerId)) return
|
||||
salesApi.listInvoices({ customerId, pageSize: 100 }).then((res) => setInvoices(res.items)).catch(() => setInvoices([]))
|
||||
}, [customerId])
|
||||
|
||||
const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices])
|
||||
const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices])
|
||||
const recentInvoices = useMemo(
|
||||
() => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()),
|
||||
[invoices]
|
||||
)
|
||||
|
||||
const [invoicePage, setInvoicePage] = useState(1)
|
||||
const [invoicePageSize, setInvoicePageSize] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
|
||||
const [mobileInvoiceCount, setMobileInvoiceCount] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
|
||||
const invoicePagination = {
|
||||
page: invoicePage,
|
||||
pageSize: invoicePageSize,
|
||||
totalItems: recentInvoices.length,
|
||||
totalPages: Math.max(1, Math.ceil(recentInvoices.length / invoicePageSize)),
|
||||
}
|
||||
const pagedInvoices = recentInvoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize)
|
||||
const monthlySales = useMemo(() => {
|
||||
const byMonth = new Map<string, number>()
|
||||
for (const inv of invoices ?? []) {
|
||||
const key = inv.invoiceDate.slice(0, 7)
|
||||
byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal)
|
||||
}
|
||||
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
|
||||
}, [invoices])
|
||||
|
||||
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
|
||||
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 639px)")
|
||||
setIsMobile(mql.matches)
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
if (!customer || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
|
||||
if (!name.trim()) nextErrors.name = "Customer name is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await customersApi.update(
|
||||
customerId,
|
||||
{
|
||||
customerCode,
|
||||
customerType,
|
||||
name,
|
||||
displayName: displayName || null,
|
||||
phone: phone || null,
|
||||
email: email || null,
|
||||
addressLine1: addressLine1 || null,
|
||||
addressLine2: addressLine2 || null,
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
taxRegistrationNo: taxRegistrationNo || null,
|
||||
creditLimit: Number(creditLimit) || 0,
|
||||
creditDays: Number(creditDays) || 0,
|
||||
defaultWarehouseId: customer.defaultWarehouseId,
|
||||
branchId: customer.branchId,
|
||||
},
|
||||
etag
|
||||
)
|
||||
applyCustomer(result.data)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
setEditOpen(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save customer", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!customer) return
|
||||
const next = customer.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingStatus(true)
|
||||
try {
|
||||
await customersApi.updateStatus(customerId, next)
|
||||
toast.success(next === "Active" ? "Customer activated" : "Customer deactivated")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !customer) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/customers" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to customers
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
href="/dashboard/customers"
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Customers
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Users className="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{customer.customerCode}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
customer.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{customer.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{customer.displayName ?? customer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant={customer.status === "Active" ? "destructive" : "success"}
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingStatus}
|
||||
>
|
||||
{togglingStatus ? "Updating…" : customer.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
|
||||
<StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} />
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label="Outstanding balance"
|
||||
value={invoices === null ? "…" : `LKR ${outstandingBalance.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This customer was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Monthly sales</h2>
|
||||
{invoices === null ? (
|
||||
<Skeleton className="h-85 w-full" />
|
||||
) : monthlySales.length > 0 ? (
|
||||
<BarChart
|
||||
// Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
|
||||
// activity, Chart.js would otherwise center the lone category across the full
|
||||
// width. Trailing blank slots keep the axis/gridlines spanning the full card
|
||||
// while the real bars sit at the start. Skipped on mobile — there the bars
|
||||
// should stretch to fill the narrower card instead.
|
||||
labels={[
|
||||
...monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
|
||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill("")),
|
||||
]}
|
||||
datasets={[
|
||||
{
|
||||
label: "Sold (LKR)",
|
||||
data: [
|
||||
...monthlySales.map(([, v]) => v),
|
||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill(null)),
|
||||
],
|
||||
backgroundColor: "rgba(239,68,68,0.6)",
|
||||
},
|
||||
]}
|
||||
barPercentage={0.6}
|
||||
categoryPercentage={0.6}
|
||||
height={340}
|
||||
/>
|
||||
) : (
|
||||
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2>
|
||||
{invoices === null ? (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentInvoices.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
{recentInvoices.slice(0, mobileInvoiceCount).map((inv) => (
|
||||
<Link
|
||||
key={inv.salesInvoiceId}
|
||||
href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`}
|
||||
className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm hover:bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-foreground">{inv.invoiceNo}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-muted px-2 text-xs text-muted-foreground">
|
||||
{inv.status}
|
||||
</Badge>
|
||||
<span className="font-medium text-foreground">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{mobileInvoiceCount < recentInvoices.length && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => setMobileInvoiceCount((c) => c + INVOICE_PAGE_SIZE_OPTIONS[0])}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedInvoices.map((inv) => (
|
||||
<TableRow key={inv.salesInvoiceId} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-semibold text-foreground hover:underline">
|
||||
{inv.invoiceNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
|
||||
{inv.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">
|
||||
LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={invoicePagination}
|
||||
onPageChange={setInvoicePage}
|
||||
pageSizeOptions={INVOICE_PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setInvoicePage(1); setInvoicePageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {customer.customerCode}</DialogTitle>
|
||||
<DialogDescription>Update this customer's details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Code</Label>
|
||||
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Type</Label>
|
||||
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="B2C" className="text-base">B2C — individual</SelectItem>
|
||||
<SelectItem value="B2B" className="text-base">B2B — business</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Display name (optional)</Label>
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Phone</Label>
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Email</Label>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 1</Label>
|
||||
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 2</Label>
|
||||
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">City</Label>
|
||||
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Country</Label>
|
||||
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax registration</Label>
|
||||
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit limit (LKR)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit days</Label>
|
||||
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
|
||||
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateCustomerCode } from "@/lib/customer-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Customer, CustomerType } from "@/types/customers"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type TypeFilter = CustomerType | "All"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [customers, setCustomers] = useState<Customer[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [customerType, setCustomerType] = useState<TypeFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [creditLimit, setCreditLimit] = useState("")
|
||||
const [creditDays, setCreditDays] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Every other customer's code, to de-dupe against when the name edit regenerates this
|
||||
// customer's own code (mirrors the vendor create dialog).
|
||||
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
customersApi
|
||||
.list({
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
customerType: customerType === "All" ? undefined : customerType,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
.then((res) => {
|
||||
setCustomers(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
||||
useEffect(() => {
|
||||
if (branchingEnabled && isAdmin) {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}
|
||||
}, [branchingEnabled, isAdmin])
|
||||
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
|
||||
function resetForm() {
|
||||
setName("")
|
||||
setNewCustomerType("B2C")
|
||||
setPhone("")
|
||||
setEmail("")
|
||||
setCreditLimit("")
|
||||
setCreditDays("")
|
||||
setBranchFilterId(null)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!name.trim()) nextErrors.name = "Customer name is required"
|
||||
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await customersApi.create({
|
||||
customerCode: generatedCode,
|
||||
customerType: newCustomerType,
|
||||
name,
|
||||
phone: phone || null,
|
||||
email: email || null,
|
||||
creditLimit: Number(creditLimit) || 0,
|
||||
creditDays: Number(creditDays) || 0,
|
||||
branchId: effectiveBranchId ?? 0,
|
||||
})
|
||||
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
setAllCustomerCodes((codes) => [...codes, result.data.customerCode])
|
||||
} catch (err) {
|
||||
toast.error("Could not create customer", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI maps to
|
||||
* PATCH status → Inactive, same mechanism as the profile page's toggle. */
|
||||
async function handleDeactivate(customer: Customer) {
|
||||
setActionPendingId(customer.customerId)
|
||||
try {
|
||||
await customersApi.updateStatus(customer.customerId, "Inactive")
|
||||
toast.success("Customer deleted", `${customer.customerCode} has been deactivated.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete customer", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Customers</h1>
|
||||
<p className="text-base text-muted-foreground">Customer master data used by sales invoices, slips and bundles.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg" onClick={resetForm}>
|
||||
<Plus className="size-5" />
|
||||
New Customer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New customer</DialogTitle>
|
||||
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="c-name">Name</FieldLabel>
|
||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="c-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-type">Customer type</FieldLabel>
|
||||
<Select<CustomerType> value={newCustomerType} onValueChange={(v) => v && setNewCustomerType(v)}>
|
||||
<SelectTrigger id="c-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="B2C">B2C — individual</SelectItem>
|
||||
<SelectItem value="B2B">B2B — business</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
||||
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
|
||||
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{showBranchPicker && (
|
||||
<Field data-invalid={!!errors.branch}>
|
||||
<FieldLabel htmlFor="c-branch">Branch</FieldLabel>
|
||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
|
||||
<SelectValue placeholder="Select branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.branch ? { message: errors.branch } : 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-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search code or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search customers"
|
||||
/>
|
||||
</div>
|
||||
<Select<TypeFilter> value={customerType} onValueChange={(v) => setCustomerType(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All types</SelectItem>
|
||||
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
|
||||
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && customers === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && customers !== null && customers.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Users className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No customers match your search/filter." : "No customers yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && customers !== null && customers.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{customers.map((c) => (
|
||||
<div
|
||||
key={c.customerId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/customers/${c.customerId}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{c.customerCode}</p>
|
||||
<p className="text-sm text-muted-foreground">{c.displayName ?? c.name}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Phone</p>
|
||||
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Credit limit</p>
|
||||
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/customers/${c.customerId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${c.customerCode}`}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${c.customerCode}`}
|
||||
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.customerCode}?`}
|
||||
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeactivate(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && customers.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-32">Code</TableHead>
|
||||
<TableHead className="w-48">Name</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-36">Phone</TableHead>
|
||||
<TableHead className="w-36">Credit limit</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customers.map((c) => (
|
||||
<TableRow
|
||||
key={c.customerId}
|
||||
className="align-top cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/customers/${c.customerId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
|
||||
{c.customerCode}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.displayName ?? c.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{c.customerType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/customers/${c.customerId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${c.customerCode}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/customers/${c.customerId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${c.customerCode}`}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${c.customerCode}`}
|
||||
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.customerCode}?`}
|
||||
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeactivate(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Eye,
|
||||
HelpCircle,
|
||||
Inbox,
|
||||
Plus,
|
||||
@@ -20,7 +21,7 @@ import { SupportTicketSummary } from "@/types/support"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
@@ -32,16 +33,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size
|
||||
// client-side, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
Open: "border-transparent bg-primary/10 text-primary",
|
||||
@@ -64,7 +61,6 @@ export default function HelpPage() {
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [subject, setSubject] = useState("")
|
||||
@@ -89,12 +85,6 @@ export default function HelpPage() {
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => setPage(1), [statusFilter])
|
||||
|
||||
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
|
||||
setStatusFilter((s) => (s === status ? null : status))
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!tickets) return null
|
||||
return {
|
||||
@@ -108,12 +98,11 @@ export default function HelpPage() {
|
||||
const filtered = useMemo(() => {
|
||||
if (!tickets) return []
|
||||
const term = debouncedSearch.toLowerCase()
|
||||
if (!term) return tickets
|
||||
return tickets.filter(
|
||||
(t) =>
|
||||
(!statusFilter || t.status === statusFilter) &&
|
||||
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
|
||||
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
|
||||
)
|
||||
}, [tickets, debouncedSearch, statusFilter])
|
||||
}, [tickets, debouncedSearch])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
@@ -216,40 +205,16 @@ export default function HelpPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total tickets"
|
||||
value={stats.total}
|
||||
icon={HelpCircle}
|
||||
active={statusFilter === null}
|
||||
onClick={() => setStatusFilter(null)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Open"
|
||||
value={stats.open}
|
||||
icon={Inbox}
|
||||
active={statusFilter === "Open"}
|
||||
onClick={() => toggleStatusFilter("Open")}
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={stats.inProgress}
|
||||
icon={Clock}
|
||||
active={statusFilter === "InProgress"}
|
||||
onClick={() => toggleStatusFilter("InProgress")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Resolved"
|
||||
value={stats.resolved}
|
||||
icon={CheckCircle2}
|
||||
active={statusFilter === "Resolved"}
|
||||
onClick={() => toggleStatusFilter("Resolved")}
|
||||
/>
|
||||
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
|
||||
<StatCard label="Open" value={stats.open} icon={Inbox} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -297,17 +262,15 @@ export default function HelpPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
|
||||
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{debouncedSearch ? <>No tickets match "{debouncedSearch}"</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
|
||||
<p className="text-base font-medium text-foreground">No tickets match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
|
||||
Clear filters
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -325,119 +288,98 @@ export default function HelpPage() {
|
||||
)}
|
||||
|
||||
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pageItems.map((t) => (
|
||||
<div key={t.ticketId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
|
||||
{statusLabels[t.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Submitted By</p>
|
||||
<p className="font-medium text-foreground">{t.createdByName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Messages</p>
|
||||
<p className="font-medium text-foreground">{t.messageCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last Updated</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t pt-3">
|
||||
<Link href={`/dashboard/help/tickets/${t.ticketId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Eye className="size-4" />
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{totalItems > pageItems.length && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
|
||||
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
|
||||
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((t) => (
|
||||
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/help/tickets/${t.ticketId}`}
|
||||
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{t.subject}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
|
||||
{statusLabels[t.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((t) => (
|
||||
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/help/tickets/${t.ticketId}`}
|
||||
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{t.subject}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
|
||||
{statusLabels[t.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/help/tickets/${t.ticketId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ticket ${t.subject}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{totalItems}</span> tickets
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
|
||||
|
||||
{isAdmin ? (
|
||||
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
|
||||
<SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Open">Open</SelectItem>
|
||||
<SelectItem value="InProgress">In Progress</SelectItem>
|
||||
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
|
||||
{ticket.messages.map((m) => (
|
||||
<div
|
||||
key={m.messageId}
|
||||
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
|
||||
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`}
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
|
||||
</span>
|
||||
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
|
||||
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
|
||||
rows={3}
|
||||
/>
|
||||
<Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
|
||||
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
|
||||
{sending ? "Sending…" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
|
||||
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
|
||||
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
|
||||
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
|
||||
@@ -148,95 +148,47 @@ export default function AttendanceBatchDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{records.map((r) => (
|
||||
<div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
|
||||
<p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">In / Out</p>
|
||||
<p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Working Hours</p>
|
||||
<p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Late</p>
|
||||
<p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">OT</p>
|
||||
<p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Row status</p>
|
||||
<p className="font-medium text-foreground">{r.attendanceStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
|
||||
<div className="flex gap-2 border-t pt-3">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
|
||||
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.attendanceRecordId}>
|
||||
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
|
||||
</TableCell>
|
||||
{!locked && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
|
||||
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.attendanceRecordId}>
|
||||
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
|
||||
</TableCell>
|
||||
{!locked && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Download, Plus, Upload } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Download, Eye, Plus, Upload } from "lucide-react"
|
||||
|
||||
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -15,15 +15,11 @@ import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 25
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -33,13 +29,10 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
||||
}
|
||||
|
||||
export default function AttendanceBatchesPage() {
|
||||
const router = useRouter()
|
||||
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [periodStart, setPeriodStart] = useState("")
|
||||
@@ -49,17 +42,11 @@ export default function AttendanceBatchesPage() {
|
||||
|
||||
function load() {
|
||||
attendanceApi
|
||||
.list({ page, pageSize })
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
useEffect(load, [page, pageSize])
|
||||
useEffect(load, [page])
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) { toast.error("Choose an Excel or CSV file first"); return }
|
||||
@@ -87,12 +74,12 @@ export default function AttendanceBatchesPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
|
||||
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
|
||||
<div className="flex gap-2">
|
||||
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
<Download className="size-4" />Download template
|
||||
</a>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Upload Attendance</DialogTitle>
|
||||
@@ -123,83 +110,41 @@ export default function AttendanceBatchesPage() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
|
||||
)}
|
||||
{!error && batches !== null && batches.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.map((b) => (
|
||||
<div
|
||||
key={b.attendanceUploadBatchId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{b.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Rows</p>
|
||||
<p className="font-medium text-foreground">{b.rowCountTotal}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Duplicates/Errors</p>
|
||||
<p className="font-medium text-foreground">{b.rowCountDuplicate} / {b.rowCountError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow key={b.attendanceUploadBatchId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{pagination && batches.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.map((b) => (
|
||||
<TableRow
|
||||
key={b.attendanceUploadBatchId}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useSearchParams } from "next/navigation"
|
||||
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
|
||||
|
||||
@@ -44,14 +44,6 @@ function money(n: number): string {
|
||||
}
|
||||
|
||||
export default function EmployeeDetailPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<EmployeeDetailPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function EmployeeDetailPageContent() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const employeeId = Number(params.id)
|
||||
@@ -335,7 +327,7 @@ function EmployeeDetailPageContent() {
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn("shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors sm:flex-1", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
@@ -436,12 +428,12 @@ function EmployeeDetailPageContent() {
|
||||
</section>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
|
||||
<X className="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -460,12 +452,12 @@ function EmployeeDetailPageContent() {
|
||||
</section>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
|
||||
<X className="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
||||
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -493,7 +485,7 @@ function EmployeeDetailPageContent() {
|
||||
</Field>
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-black px-4 text-sm font-medium sm:w-fit",
|
||||
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
|
||||
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
|
||||
)}
|
||||
title={uploadTypeId ? undefined : "Select a document type first"}
|
||||
@@ -510,105 +502,65 @@ function EmployeeDetailPageContent() {
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{documents.length === 0 ? (
|
||||
<div className="rounded-2xl bg-card p-10 text-center text-muted-foreground shadow-sm ring-1 ring-foreground/10">No documents uploaded yet.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((d) => (
|
||||
<div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
|
||||
<p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
|
||||
</div>
|
||||
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-3 text-sm">
|
||||
<span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
|
||||
<a
|
||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Download ${d.originalFileName}`}
|
||||
title={`Download ${d.originalFileName}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Download ${d.originalFileName}`}
|
||||
title={`Download ${d.originalFileName}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((d) => (
|
||||
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Download ${d.originalFileName}`}
|
||||
title={`Download ${d.originalFileName}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{documents.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Salary & Loans" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
|
||||
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
|
||||
@@ -692,63 +644,37 @@ function EmployeeDetailPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{salaryStructures.length === 0 ? (
|
||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{salaryStructures.map((s) => (
|
||||
<div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground">{s.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t pt-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Effective to</p>
|
||||
<p className="font-medium text-foreground">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Basic</p>
|
||||
<p className="font-medium text-foreground">{money(s.basicSalary)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{salaryStructures.map((s) => (
|
||||
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{salaryStructures.length === 0 && (
|
||||
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
||||
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
|
||||
@@ -784,61 +710,35 @@ function EmployeeDetailPageContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{loans.length === 0 ? (
|
||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loans.map((l) => (
|
||||
<div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground">{l.docNo}</span>
|
||||
<span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t pt-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Principal</p>
|
||||
<p className="font-medium text-foreground">{money(l.principalAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Outstanding</p>
|
||||
<p className="font-medium text-foreground">{money(l.outstandingBalance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loans.map((l) => (
|
||||
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{loans.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 flex flex-col-reverse gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
|
||||
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6">
|
||||
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
|
||||
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
|
||||
<Save className="size-4" />
|
||||
{submitting ? "Creating…" : "Create employee"}
|
||||
</Button>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
|
||||
@@ -16,10 +15,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
@@ -29,12 +26,8 @@ import { toast } from "@/components/ui/toast"
|
||||
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
|
||||
@@ -46,7 +39,6 @@ export default function EmployeesPage() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
|
||||
|
||||
@@ -70,12 +62,6 @@ export default function EmployeesPage() {
|
||||
})
|
||||
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
|
||||
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
|
||||
@@ -167,31 +153,13 @@ export default function EmployeesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total employees"
|
||||
value={stats.total}
|
||||
icon={UsersIcon}
|
||||
active={statusFilter === undefined}
|
||||
onClick={() => setStatusFilter(undefined)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
icon={UserCheck}
|
||||
active={statusFilter === "Active"}
|
||||
onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))}
|
||||
/>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard
|
||||
label="Terminated"
|
||||
value={stats.terminated}
|
||||
icon={UserMinus}
|
||||
active={statusFilter === "Terminated"}
|
||||
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
|
||||
/>
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
@@ -292,240 +260,159 @@ export default function EmployeesPage() {
|
||||
)}
|
||||
|
||||
{!error && employees !== null && employees.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{employees.map((e) => (
|
||||
<div
|
||||
key={e.employeeId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(ev) => {
|
||||
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
|
||||
}}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{e.fullName}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((e) => (
|
||||
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{e.fullName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Department</p>
|
||||
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Designation</p>
|
||||
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Hire Date</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<TableCell className="px-3 py-4">
|
||||
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Login</p>
|
||||
{e.hasUserLink ? (
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
||||
) : (
|
||||
<p className="font-medium text-muted-foreground">No login</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${e.fullName}`}
|
||||
title={`View ${e.fullName}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${e.fullName}`}
|
||||
title={`Edit ${e.fullName}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${e.fullName}`}
|
||||
title={`Delete ${e.fullName}`}
|
||||
disabled={actionPendingId === e.employeeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${e.fullName}?`}
|
||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteRequest(e)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<TableCell className="px-3 py-4">
|
||||
{e.hasUserLink ? (
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No login</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{pagination && employees.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((e) => (
|
||||
<TableRow
|
||||
key={e.employeeId}
|
||||
className="group align-top hover:bg-muted/20 cursor-pointer"
|
||||
onClick={(ev) => {
|
||||
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{e.fullName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
{e.hasUserLink ? (
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No login</span>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
</TableCell>
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${e.fullName}`}
|
||||
title={`Delete ${e.fullName}`}
|
||||
disabled={actionPendingId === e.employeeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${e.fullName}?`}
|
||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteRequest(e)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${e.fullName}`}
|
||||
title={`Delete ${e.fullName}`}
|
||||
disabled={actionPendingId === e.employeeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${e.fullName}?`}
|
||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteRequest(e)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Clock, PenLine, Plus, Search, X, XCircle } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||
@@ -10,6 +10,7 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -17,7 +18,6 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -25,9 +25,6 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
|
||||
const ALL_STATUS = "__all__"
|
||||
|
||||
@@ -40,8 +37,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
|
||||
}
|
||||
|
||||
export default function LeaveRequestsPage() {
|
||||
const stats = useDashboardStats()
|
||||
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -50,7 +47,6 @@ export default function LeaveRequestsPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
|
||||
|
||||
@@ -80,32 +76,10 @@ export default function LeaveRequestsPage() {
|
||||
})
|
||||
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status. Each call falls back
|
||||
// to 0 on its own so one bad request doesn't blank out the other tiles.
|
||||
function loadCounts() {
|
||||
Promise.all(
|
||||
STATUSES.map((s) =>
|
||||
leaveRequestsApi
|
||||
.list({ page: 1, pageSize: 1, status: s })
|
||||
.then((res) => res.pagination.totalItems)
|
||||
.catch(() => 0)
|
||||
)
|
||||
).then((values) => {
|
||||
setStatusCounts(Object.fromEntries(STATUSES.map((s, i) => [s, values[i]])) as Record<LeaveRequestStatus, number>)
|
||||
})
|
||||
}
|
||||
useEffect(loadCounts, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
@@ -128,7 +102,7 @@ export default function LeaveRequestsPage() {
|
||||
toast.success("Leave request submitted")
|
||||
setOpen(false)
|
||||
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
|
||||
load(); loadCounts()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not submit leave request", errorMessage(err))
|
||||
} finally {
|
||||
@@ -141,7 +115,7 @@ export default function LeaveRequestsPage() {
|
||||
try {
|
||||
await leaveRequestsApi.approve(id)
|
||||
toast.success("Leave approved")
|
||||
load(); loadCounts()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve", errorMessage(err))
|
||||
} finally {
|
||||
@@ -164,7 +138,7 @@ export default function LeaveRequestsPage() {
|
||||
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
|
||||
toast.success("Leave rejected")
|
||||
setRejectTarget(null)
|
||||
load(); loadCounts()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not reject", errorMessage(err))
|
||||
} finally {
|
||||
@@ -253,49 +227,7 @@ export default function LeaveRequestsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<KpiRow
|
||||
tiles={
|
||||
statusCounts
|
||||
? [
|
||||
{
|
||||
label: "Draft",
|
||||
value: statusCounts.Draft,
|
||||
icon: PenLine,
|
||||
active: statusFilter === "Draft",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Draft" ? ALL_STATUS : "Draft")) },
|
||||
},
|
||||
{
|
||||
label: "Pending Leave",
|
||||
value: statusCounts.Submitted,
|
||||
icon: Clock,
|
||||
active: statusFilter === "Submitted",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
value: statusCounts.Approved,
|
||||
icon: CheckCircle2,
|
||||
active: statusFilter === "Approved",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Approved" ? ALL_STATUS : "Approved")) },
|
||||
},
|
||||
{
|
||||
label: "Rejected",
|
||||
value: statusCounts.Rejected,
|
||||
icon: XCircle,
|
||||
active: statusFilter === "Rejected",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Rejected" ? ALL_STATUS : "Rejected")) },
|
||||
},
|
||||
{
|
||||
label: "Cancelled",
|
||||
value: statusCounts.Cancelled,
|
||||
icon: X,
|
||||
active: statusFilter === "Cancelled",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Cancelled" ? ALL_STATUS : "Cancelled")) },
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
@@ -371,155 +303,124 @@ export default function LeaveRequestsPage() {
|
||||
)}
|
||||
|
||||
{!error && requests !== null && requests.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{requests.map((r) => (
|
||||
<div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Days</p>
|
||||
<p className="font-medium text-foreground">{r.daysCount}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Dates</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((r) => (
|
||||
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{r.status === "Submitted" && (
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-success hover:bg-success/10"
|
||||
aria-label={`Approve ${r.docNo}`}
|
||||
title={`Approve ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => approve(r.leaveRequestId)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Reject ${r.docNo}`}
|
||||
title={`Reject ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => openReject(r)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && requests.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
{r.status === "Submitted" ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-success hover:bg-success/10"
|
||||
aria-label={`Approve ${r.docNo}`}
|
||||
title={`Approve ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => approve(r.leaveRequestId)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Reject ${r.docNo}`}
|
||||
title={`Reject ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => openReject(r)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex justify-center text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((r) => (
|
||||
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
{r.status === "Submitted" ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-success hover:bg-success/10"
|
||||
aria-label={`Approve ${r.docNo}`}
|
||||
title={`Approve ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => approve(r.leaveRequestId)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Reject ${r.docNo}`}
|
||||
title={`Reject ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => openReject(r)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex justify-center text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
||||
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Period</p>
|
||||
<p className="font-semibold text-foreground">{period}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -14,38 +15,18 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size
|
||||
// client-side, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
function money(n: number): string {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** Small tinted stat chip — value + label + icon. */
|
||||
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; 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 PayrollRunDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const runId = Number(params.id)
|
||||
|
||||
const [run, setRun] = useState<PayrollRun | null>(null)
|
||||
@@ -155,11 +136,11 @@ export default function PayrollRunDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
|
||||
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
|
||||
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
|
||||
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
|
||||
<StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div>
|
||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div>
|
||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div>
|
||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -196,146 +177,114 @@ export default function PayrollRunDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pagedLines.map((l) => {
|
||||
const totalDeductions = l.grossSalary - l.netSalary
|
||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||
return (
|
||||
<div
|
||||
key={l.payrollLineId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{l.employeeName}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
|
||||
</div>
|
||||
{payslip && (
|
||||
<a
|
||||
href={payslipViewUrl(payslip.payslipId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-no-row-nav
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
|
||||
aria-label={`View payslip for ${l.employeeName}`}
|
||||
title="View payslip"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Basic</p>
|
||||
<p className="font-medium text-foreground">{money(l.basicSalary)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">OT</p>
|
||||
<p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Allowances</p>
|
||||
<p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Deductions</p>
|
||||
<p className="font-medium text-foreground">{money(totalDeductions)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Net Salary</p>
|
||||
<p className="font-semibold text-foreground">{money(l.netSalary)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredLines.length > pagedLines.length && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedLines.map((l) => {
|
||||
const totalDeductions = l.grossSalary - l.netSalary
|
||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||
return (
|
||||
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
||||
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View breakdown for ${l.employeeName}`}
|
||||
title="View breakdown"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{payslip && (
|
||||
<a
|
||||
href={payslipViewUrl(payslip.payslipId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View payslip for ${l.employeeName}`}
|
||||
title="View payslip"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedLines.map((l) => {
|
||||
const totalDeductions = l.grossSalary - l.netSalary
|
||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||
return (
|
||||
<TableRow
|
||||
key={l.payrollLineId}
|
||||
className="cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
||||
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{payslip && (
|
||||
<a
|
||||
href={payslipViewUrl(payslip.payslipId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View payslip for ${l.employeeName}`}
|
||||
title="View payslip"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLines.length > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Eye, Plus } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -17,15 +16,11 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
|
||||
const ALL_STATUS = "__all__"
|
||||
|
||||
@@ -36,14 +31,12 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
||||
}
|
||||
|
||||
export default function PayrollRunsPage() {
|
||||
const router = useRouter()
|
||||
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const now = new Date()
|
||||
@@ -56,12 +49,6 @@ export default function PayrollRunsPage() {
|
||||
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
|
||||
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -154,112 +141,102 @@ export default function PayrollRunsPage() {
|
||||
)}
|
||||
|
||||
{!error && runs !== null && runs.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{runs.map((r) => (
|
||||
<div
|
||||
key={r.payrollRunId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Employees</p>
|
||||
<p className="font-medium text-foreground">{r.employeeCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Gross</p>
|
||||
<p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Net</p>
|
||||
<p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && runs.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${r.docNo}`}
|
||||
title={`View ${r.docNo}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow
|
||||
key={r.payrollRunId}
|
||||
className="cursor-pointer hover:bg-muted/20"
|
||||
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
|
||||
>
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label={`View payroll run ${r.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -15,31 +15,24 @@ import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const NONE = "__none__"
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
const [items, setItems] = useState<Department[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const { branchingEnabled } = useAuth()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -66,12 +59,6 @@ export default function DepartmentsPage() {
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -229,20 +216,18 @@ export default function DepartmentsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
||||
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
||||
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
@@ -286,24 +271,22 @@ export default function DepartmentsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -372,209 +355,151 @@ export default function DepartmentsPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((d) => (
|
||||
<div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{d.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{d.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Parent</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((d) => (
|
||||
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title={`Edit ${d.name}`}
|
||||
onClick={() => openEdit(d)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
d.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
onClick={() => toggleStatus(d)}
|
||||
>
|
||||
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${d.name}`}
|
||||
title={`Delete ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${d.name}?`}
|
||||
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(d)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((d) => (
|
||||
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title={`Edit ${d.name}`}
|
||||
onClick={() => openEdit(d)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title={`Edit ${d.name}`}
|
||||
onClick={() => openEdit(d)}
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
d.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
onClick={() => toggleStatus(d)}
|
||||
>
|
||||
{d.status === "Active" ? <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 ${d.name}`}
|
||||
title={`Delete ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
d.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
onClick={() => toggleStatus(d)}
|
||||
>
|
||||
{d.status === "Active" ? <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 ${d.name}`}
|
||||
title={`Delete ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${d.name}?`}
|
||||
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(d)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${d.name}?`}
|
||||
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(d)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -16,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -24,9 +23,6 @@ import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function DocumentTypesPage() {
|
||||
const [items, setItems] = useState<HrDocumentType[] | null>(null)
|
||||
@@ -37,7 +33,6 @@ export default function DocumentTypesPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -63,12 +58,6 @@ export default function DocumentTypesPage() {
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -271,9 +260,9 @@ export default function DocumentTypesPage() {
|
||||
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||
</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={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -342,205 +331,149 @@ export default function DocumentTypesPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((t) => (
|
||||
<div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Category</p>
|
||||
<p className="font-medium text-foreground">{t.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <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 ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <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 ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -16,15 +16,11 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function LeaveTypesPage() {
|
||||
const [items, setItems] = useState<LeaveType[] | null>(null)
|
||||
@@ -35,7 +31,6 @@ export default function LeaveTypesPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -64,12 +59,6 @@ export default function LeaveTypesPage() {
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -289,9 +278,9 @@ export default function LeaveTypesPage() {
|
||||
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
|
||||
</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={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -361,211 +350,151 @@ export default function LeaveTypesPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((t) => (
|
||||
<div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Days/yr</p>
|
||||
<p className="font-medium text-foreground">{t.accrualPerYear}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Paid</p>
|
||||
<p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
||||
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
||||
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <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 ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <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 ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function SalaryComponentsPage() {
|
||||
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
||||
@@ -36,7 +32,6 @@ export default function SalaryComponentsPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -61,12 +56,6 @@ export default function SalaryComponentsPage() {
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -264,9 +253,9 @@ export default function SalaryComponentsPage() {
|
||||
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
|
||||
</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={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -335,205 +324,149 @@ export default function SalaryComponentsPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((c) => (
|
||||
<div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{c.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{c.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<p className="font-medium text-foreground">{c.componentType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
title={`Edit ${c.name}`}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
c.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
onClick={() => toggleStatus(c)}
|
||||
>
|
||||
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${c.name}`}
|
||||
title={`Delete ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.name}?`}
|
||||
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
aria-label={`Edit ${c.name}`}
|
||||
title={`Edit ${c.name}`}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
title={`Edit ${c.name}`}
|
||||
onClick={() => openEdit(c)}
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
c.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
onClick={() => toggleStatus(c)}
|
||||
>
|
||||
{c.status === "Active" ? <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 ${c.name}`}
|
||||
title={`Delete ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
c.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
onClick={() => toggleStatus(c)}
|
||||
>
|
||||
{c.status === "Active" ? <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 ${c.name}`}
|
||||
title={`Delete ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.name}?`}
|
||||
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.name}?`}
|
||||
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -141,66 +141,34 @@ export default function StatutorySettingsPage() {
|
||||
)}
|
||||
|
||||
{settings !== null && settings.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{settings.map((s) => (
|
||||
<div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="font-semibold text-foreground">
|
||||
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">EPF (Employee)</p>
|
||||
<p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">EPF (Employer)</p>
|
||||
<p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">ETF (Employer)</p>
|
||||
<p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">OT multiplier</p>
|
||||
<p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map((s) => (
|
||||
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">
|
||||
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map((s) => (
|
||||
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">
|
||||
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -257,46 +225,28 @@ export default function StatutorySettingsPage() {
|
||||
)}
|
||||
|
||||
{slabs !== null && slabs.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{slabs.map((s) => (
|
||||
<div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
|
||||
<p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<p className="border-t pt-3 text-sm text-muted-foreground">
|
||||
{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slabs.map((s) => (
|
||||
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slabs.map((s) => (
|
||||
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
function maskToDays(mask: number): boolean[] {
|
||||
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
|
||||
@@ -40,7 +36,6 @@ export default function WorkShiftsPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -79,12 +74,6 @@ export default function WorkShiftsPage() {
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -228,7 +217,7 @@ export default function WorkShiftsPage() {
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New Work Shift</DialogTitle>
|
||||
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
|
||||
@@ -297,7 +286,7 @@ export default function WorkShiftsPage() {
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Work Shift</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
@@ -366,9 +355,9 @@ export default function WorkShiftsPage() {
|
||||
</div>
|
||||
</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={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -437,205 +426,149 @@ export default function WorkShiftsPage() {
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{items.map((w) => (
|
||||
<div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{w.name}</p>
|
||||
<span className="font-mono text-xs text-muted-foreground">{w.code}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Hours</p>
|
||||
<p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((w) => (
|
||||
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${w.name}`}
|
||||
title={`Edit ${w.name}`}
|
||||
onClick={() => openEdit(w)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
w.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
onClick={() => toggleStatus(w)}
|
||||
>
|
||||
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${w.name}`}
|
||||
title={`Delete ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${w.name}?`}
|
||||
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(w)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && items.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((w) => (
|
||||
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
aria-label={`Edit ${w.name}`}
|
||||
title={`Edit ${w.name}`}
|
||||
onClick={() => openEdit(w)}
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${w.name}`}
|
||||
title={`Edit ${w.name}`}
|
||||
onClick={() => openEdit(w)}
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
w.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
onClick={() => toggleStatus(w)}
|
||||
>
|
||||
{w.status === "Active" ? <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 ${w.name}`}
|
||||
title={`Delete ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
w.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
onClick={() => toggleStatus(w)}
|
||||
>
|
||||
{w.status === "Active" ? <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 ${w.name}`}
|
||||
title={`Delete ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${w.name}?`}
|
||||
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(w)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${w.name}?`}
|
||||
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(w)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
|
||||
)}
|
||||
|
||||
{!error && report !== null && !isEmpty && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
||||
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
|
||||
)}
|
||||
|
||||
{!error && rows !== null && rows.length > 0 && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
||||
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
||||
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
||||
{[...rows]
|
||||
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
||||
.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Table className="mt-4 text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
||||
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
||||
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
||||
{[...rows]
|
||||
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
||||
.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
|
||||
)}
|
||||
|
||||
{!error && report !== null && !isEmpty && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<ReportHeader
|
||||
title="Statement of Profit or Loss"
|
||||
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
|
||||
)}
|
||||
|
||||
{!error && report !== null && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
|
||||
identity block (company name/address/TIN/BRN) that today lives only in GL's own
|
||||
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
|
||||
|
||||
@@ -97,38 +97,36 @@ export default function TrialBalancePage() {
|
||||
)}
|
||||
|
||||
{!error && rows !== null && rows.length > 0 && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
<Table className="mt-4 text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5">
|
||||
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
|
||||
<span>{row.accountName}</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5">
|
||||
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
|
||||
<span>{row.accountName}</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
BadgeDollarSign,
|
||||
Boxes,
|
||||
Clock,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
@@ -129,91 +128,92 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/purchase-orders">
|
||||
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20 w-52 rounded-2xl" />)
|
||||
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#E11D48",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {TREND_DAYS} days)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<LineChart
|
||||
labels={movementTrend.labels}
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
datasets={[
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#6366f1",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {TREND_DAYS} days)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<LineChart
|
||||
labels={movementTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
@@ -229,111 +229,48 @@ export default function DashboardPage() {
|
||||
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
||||
)}
|
||||
>
|
||||
{entry.direction === "In" ? (
|
||||
<ArrowDownLeft className="size-4 text-success" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty</p>
|
||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Source</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-160">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status !== "Draft" && (
|
||||
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<BookOpen className="size-5" />
|
||||
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
||||
<section className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="border-b pb-4">
|
||||
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
|
||||
<div>
|
||||
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
{line.grnDocNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
||||
|
||||
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
||||
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
|
||||
<Button size="lg" type="button" onClick={submit} disabled={saving}>
|
||||
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,33 +2,28 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
||||
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { dashboardApi } from "@/lib/api/dashboard"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
|
||||
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = PurchaseInvoiceStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
const PAGE_SIZE = 10
|
||||
const tabs: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
@@ -37,7 +32,6 @@ const tabs: { value: StatusFilter; label: string }[] = [
|
||||
]
|
||||
|
||||
export default function PurchaseInvoicesPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
@@ -50,15 +44,6 @@ export default function PurchaseInvoicesPage() {
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
dashboardApi
|
||||
.stats({ branchId })
|
||||
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
|
||||
.catch(() => setMonthlyPurchases([]))
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -154,30 +139,6 @@ export default function PurchaseInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
|
||||
</div>
|
||||
|
||||
{monthlyPurchases === null ? (
|
||||
<Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
) : monthlyPurchases.length > 0 ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<BarChart
|
||||
labels={monthlyPurchases.map((m) =>
|
||||
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
|
||||
)}
|
||||
datasets={[
|
||||
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -228,99 +189,7 @@ export default function PurchaseInvoicesPage() {
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-4 md:hidden">
|
||||
{visibleRows.map((row) => (
|
||||
<div
|
||||
key={row.purchaseInvoiceId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
|
||||
data-no-row-nav
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{row.docNo}
|
||||
</Link>
|
||||
<p className="truncate text-sm text-muted-foreground">{row.vendorSnapshotName}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<PurchaseInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Vendor invoice no.</p>
|
||||
<p className="font-medium text-foreground">{row.vendorInvoiceNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">GRNs</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{row.grnCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={confirmingId === row.purchaseInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
)}
|
||||
{row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -339,14 +208,7 @@ export default function PurchaseInvoicesPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.purchaseInvoiceId}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
||||
}}
|
||||
>
|
||||
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
|
||||
<TableCell className="px-4 py-3.5 font-medium">
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
|
||||
{row.docNo}
|
||||
@@ -354,10 +216,10 @@ export default function PurchaseInvoicesPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<TableCell className="px-4 py-3.5">
|
||||
@@ -372,7 +234,7 @@ export default function PurchaseInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
||||
<TableCell className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
@@ -399,6 +261,14 @@ export default function PurchaseInvoicesPage() {
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Show invoice ${row.docNo}`}
|
||||
title="Show invoice"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -424,9 +294,22 @@ export default function PurchaseInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="border-t px-4 py-4">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -56,7 +55,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const poId = Number(params.id)
|
||||
const { isAdmin, userId } = useAuth()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [po, setPo] = useState<PurchaseOrder | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
@@ -122,9 +121,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poId])
|
||||
|
||||
// Searchable item picker options — the label is what the combobox filters on.
|
||||
const itemOptions = items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
@@ -178,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
|
||||
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
|
||||
setPo(result.data)
|
||||
setEtag(result.etag)
|
||||
setLines(toDraftLines(result.data))
|
||||
@@ -311,19 +307,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
// a Draft is deleted instead, and closed/cancelled POs are terminal.
|
||||
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
|
||||
|
||||
// Mirrors PurchaseOrderService.LoadForApprovalDecisionAsync: only an Admin who didn't
|
||||
// create the PO may approve/reject it. Explain why instead of silently hiding the buttons.
|
||||
const isOwnPo = userId !== null && po.createdBy === userId
|
||||
const approvalBlockedReason =
|
||||
po.status !== "PendingApproval"
|
||||
? null
|
||||
: !isAdmin
|
||||
? "This purchase order is awaiting approval. Only an Admin can approve or reject it."
|
||||
: isOwnPo
|
||||
? "You created this purchase order, so you can't approve or reject it yourself. Another Admin must review it."
|
||||
: null
|
||||
const canDecide = po.status === "PendingApproval" && approvalBlockedReason === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -339,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit for approval"}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
|
||||
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
{deleting ? "Deleting…" : "Delete draft"}
|
||||
@@ -363,13 +346,13 @@ export default function PurchaseOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{canDecide && (
|
||||
{po.status === "PendingApproval" && isAdmin && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
|
||||
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}>
|
||||
<Check className="size-5" />
|
||||
{approving ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
|
||||
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
|
||||
<X className="size-5" />
|
||||
Reject
|
||||
</Button>
|
||||
@@ -379,7 +362,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowCancelForm(true)}
|
||||
disabled={hasReceipts}
|
||||
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
||||
@@ -391,13 +373,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{approvalBlockedReason && (
|
||||
<div role="status" className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<p>{approvalBlockedReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCancelForm && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
|
||||
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
|
||||
@@ -405,13 +380,13 @@ export default function PurchaseOrderDetailPage() {
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="Reason (e.g. Duplicate order)"
|
||||
className="h-11 w-full text-base sm:max-w-md"
|
||||
className="h-11 max-w-md text-base"
|
||||
/>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Confirm cancellation"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -462,7 +437,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{editable && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||
@@ -472,150 +447,11 @@ export default function PurchaseOrderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
|
||||
if (!editable) {
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">UOM</p>
|
||||
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Warehouse</p>
|
||||
<p className="font-medium text-foreground">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty / Received</p>
|
||||
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit price / Tax</p>
|
||||
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<FieldLabel className="text-sm">Item</FieldLabel>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 shrink-0"
|
||||
onClick={() => removeLine(line.key)}
|
||||
aria-label="Remove line"
|
||||
disabled={line.qtyReceived > 0}
|
||||
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel className="text-sm">UOM</FieldLabel>
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Warehouse</FieldLabel>
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Qty (received {line.qtyReceived})</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={line.qtyReceived}
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Unit price</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Tax</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 w-full text-base sm:w-32"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
@@ -645,17 +481,18 @@ export default function PurchaseOrderDetailPage() {
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
popupClassName="min-w-96"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
@@ -737,11 +574,11 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
|
||||
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -35,7 +34,6 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
|
||||
*/
|
||||
export default function PurchaseOrderApprovalsPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
@@ -158,14 +156,7 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
{pos.map((po) => {
|
||||
const vendor = vendorsById.get(po.vendorId)
|
||||
return (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
className="align-top cursor-pointer hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
>
|
||||
<TableRow key={po.poId} className="align-top">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
@@ -182,7 +173,7 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
{new Date(po.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -204,6 +195,14 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label="View"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
@@ -20,9 +19,7 @@ import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -36,7 +33,6 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -70,16 +66,11 @@ function NewPurchaseOrderContent() {
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
const rfqId = Number(searchParams.get("rfqId")) || null
|
||||
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
||||
// The PO's branch follows the purchase-order sub-toggle — off means no branch is asked for
|
||||
// and the server files the PO on the default branch.
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, purchaseOrderBranchingEnabled: branchingEnabled, warehouseBranchingEnabled } = useAuth()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -95,8 +86,6 @@ function NewPurchaseOrderContent() {
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vAddress, setVAddress] = useState("")
|
||||
const [vPhone, setVPhone] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
@@ -136,8 +125,6 @@ function NewPurchaseOrderContent() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVAddress("")
|
||||
setVPhone("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
@@ -156,8 +143,6 @@ function NewPurchaseOrderContent() {
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
address: vAddress || null,
|
||||
phone: vPhone || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
@@ -226,29 +211,6 @@ function NewPurchaseOrderContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requisitionId, rfqId, rfqVendorId])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
||||
useEffect(() => {
|
||||
if (branchingEnabled && isAdmin) {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}
|
||||
}, [branchingEnabled, isAdmin])
|
||||
|
||||
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
|
||||
// the dropdown only appears when the header itself is on "All Branches", so there's no
|
||||
// branch to pin to and the Admin must pick one explicitly for this PO.
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
|
||||
// Line warehouses must sit in the PO's branch only when warehouses are divided by branch
|
||||
// too (mirrors PurchaseOrderService.EnsureLinesInBranchAsync); otherwise every warehouse
|
||||
// is shared and all of them are offered.
|
||||
const lineWarehouses =
|
||||
branchingEnabled && warehouseBranchingEnabled && effectiveBranchId
|
||||
? (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)
|
||||
: (warehouses ?? [])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
@@ -257,9 +219,6 @@ function NewPurchaseOrderContent() {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
// Searchable item picker options — the label is what the combobox filters on.
|
||||
const itemOptions = (items ?? []).map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
@@ -272,10 +231,6 @@ function NewPurchaseOrderContent() {
|
||||
setHeaderError("Select a vendor.")
|
||||
return
|
||||
}
|
||||
if (branchingEnabled && !effectiveBranchId) {
|
||||
setHeaderError("Select a branch for this purchase order.")
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
@@ -313,7 +268,6 @@ function NewPurchaseOrderContent() {
|
||||
requisitionId: requisitionId ?? (rfqId ? undefined : null),
|
||||
lines: payloadLines,
|
||||
saveAsDraft,
|
||||
branchId: effectiveBranchId ?? 0,
|
||||
})
|
||||
toast.success(
|
||||
"Purchase order created",
|
||||
@@ -397,14 +351,6 @@ function NewPurchaseOrderContent() {
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-address">Address (optional)</FieldLabel>
|
||||
<Input id="po-v-address" value={vAddress} onChange={(e) => setVAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="po-v-phone" type="tel" value={vPhone} onChange={(e) => setVPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
|
||||
</Field>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
@@ -423,30 +369,6 @@ function NewPurchaseOrderContent() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{branchingEnabled && isAdmin && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Branch</Label>
|
||||
{showBranchPicker ? (
|
||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="All branches" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requisitionId && (
|
||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||
)}
|
||||
@@ -458,9 +380,9 @@ function NewPurchaseOrderContent() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard/products/new"
|
||||
target="_blank"
|
||||
@@ -479,64 +401,61 @@ function NewPurchaseOrderContent() {
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked editable cards, one per line. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm">Item</Label>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger
|
||||
className="h-11! w-full text-base"
|
||||
aria-invalid={!!errors.itemId}
|
||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 shrink-0"
|
||||
onClick={() => removeLine(line.key)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">UOM</Label>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Warehouse</Label>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lineWarehouses.map((w) => (
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
@@ -544,139 +463,42 @@ function NewPurchaseOrderContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Qty</Label>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Unit price</Label>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
popupClassName="min-w-96"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lineWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -684,14 +506,14 @@ function NewPurchaseOrderContent() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
|
||||
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
|
||||
{submitting ? "Saving…" : "Save as draft"}
|
||||
</Button>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create & submit"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -20,7 +19,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -30,17 +28,11 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const router = useRouter()
|
||||
const stats = useDashboardStats()
|
||||
// Every branch picker/column/filter on this screen follows the purchase-order sub-toggle,
|
||||
// not the global one — off means POs are one shared pool across branches.
|
||||
const { isAdmin, activeBranchId, purchaseOrderBranchingEnabled: branchingEnabled } = useAuth()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
@@ -51,10 +43,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [submittingId, setSubmittingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -66,21 +56,15 @@ export default function PurchaseOrdersListPage() {
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
|
||||
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setPos(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query, status])
|
||||
useEffect(load, [page, query, status])
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
}, [])
|
||||
@@ -92,12 +76,8 @@ export default function PurchaseOrdersListPage() {
|
||||
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [isAdmin, branchingEnabled])
|
||||
|
||||
function vendorOf(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)
|
||||
}
|
||||
|
||||
function vendorCode(vendorId: number) {
|
||||
return vendorOf(vendorId)?.code ?? `#${vendorId}`
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
function branchName(branchId: number) {
|
||||
@@ -138,53 +118,22 @@ export default function PurchaseOrdersListPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Link
|
||||
href="/dashboard/procurement/purchase-orders/approvals"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Clock className="size-5" />
|
||||
Approvals queue
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New PO
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KpiRow
|
||||
tiles={
|
||||
stats
|
||||
? [
|
||||
{
|
||||
label: "Pending Approval",
|
||||
value: stats.pendingApprovalPurchaseOrders,
|
||||
icon: Clock,
|
||||
active: status === "PendingApproval",
|
||||
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")),
|
||||
},
|
||||
{
|
||||
label: "Fully Received",
|
||||
value: stats.fullyReceivedPurchaseOrders,
|
||||
icon: PackageCheck,
|
||||
active: status === "FullyReceived",
|
||||
onClick: () => setStatus((s) => (s === "FullyReceived" ? "All" : "FullyReceived")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
@@ -221,21 +170,9 @@ export default function PurchaseOrdersListPage() {
|
||||
)}
|
||||
|
||||
{!error && pos === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -249,201 +186,45 @@ export default function PurchaseOrdersListPage() {
|
||||
|
||||
{!error && visiblePos !== null && visiblePos.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visiblePos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<div
|
||||
key={po.poId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
|
||||
{vendorOf(po.vendorId)?.name && (
|
||||
<p className="truncate text-sm text-foreground">{vendorOf(po.vendorId)?.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Grand total</p>
|
||||
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Details</p>
|
||||
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
|
||||
<>
|
||||
{vendorOf(po.vendorId)?.address && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.address}</p>}
|
||||
{vendorOf(po.vendorId)?.phone && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.phone}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="font-medium text-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(po.branchId)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Submit for approval"
|
||||
title="Submit for approval"
|
||||
disabled={submittingId === po.poId || deletingId === po.poId}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title={`Submit ${po.docNo} for approval?`}
|
||||
description="It will be locked for editing while it awaits an Admin's approval."
|
||||
confirmLabel="Submit"
|
||||
onConfirm={() => handleSubmit(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || submittingId === po.poId}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{pagination && pos !== null && pos.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-48">Doc No</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Grand total</TableHead>
|
||||
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-44 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visiblePos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
className="align-top cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
|
||||
{vendorOf(po.vendorId)?.name && (
|
||||
<p className="text-sm font-medium text-foreground">{vendorOf(po.vendorId)?.name}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-sm text-muted-foreground">
|
||||
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
|
||||
<>
|
||||
{vendorOf(po.vendorId)?.address && <p>{vendorOf(po.vendorId)?.address}</p>}
|
||||
{vendorOf(po.vendorId)?.phone && <p className="whitespace-nowrap">{vendorOf(po.vendorId)?.phone}</p>}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="View purchase order"
|
||||
aria-label="View"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
@@ -515,17 +296,30 @@ export default function PurchaseOrdersListPage() {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -210,99 +210,49 @@ function NewPurchaseReturnContent() {
|
||||
{selectedGrn && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
|
||||
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{selectedGrn.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.grnLineId] ?? {}
|
||||
return (
|
||||
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Received qty</p>
|
||||
<p className="font-medium text-foreground">{line.qty}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Hold status</p>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedGrn.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.grnLineId] ?? {}
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm">Return qty</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={state.qty}
|
||||
disabled={!state.selected}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedGrn.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.grnLineId] ?? {}
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={state.qty}
|
||||
disabled={!state.selected}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={state.qty}
|
||||
disabled={!state.selected}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -310,11 +260,11 @@ function NewPurchaseReturnContent() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Return"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -9,70 +9,31 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { PurchaseReturnSummary } from "@/types/procurement"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function PurchaseReturnsListPage() {
|
||||
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseReturnsApi
|
||||
.list({ page, pageSize, q: query || undefined })
|
||||
.then((res) => {
|
||||
setReturns(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query])
|
||||
useEffect(() => {
|
||||
Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||
.then(([v, w, rc]) => {
|
||||
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||
.then(([r, v, w, rc]) => {
|
||||
setReturns(r.items)
|
||||
setVendors(v.items)
|
||||
setWarehouses(w.items)
|
||||
setReasonCodes(rc.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function vendorCode(id: number) {
|
||||
@@ -85,8 +46,6 @@ export default function PurchaseReturnsListPage() {
|
||||
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -94,42 +53,20 @@ export default function PurchaseReturnsListPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
|
||||
</div>
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Return
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no., vendor…"
|
||||
className="h-12 w-full flex-1 basis-0 text-base"
|
||||
aria-label="Search purchase returns"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && returns === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -137,107 +74,45 @@ export default function PurchaseReturnsListPage() {
|
||||
{!error && returns !== null && returns.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageX className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Return
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-base text-muted-foreground">No purchase returns yet.</p>
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Return
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && returns !== null && returns.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{returns.map((r) => (
|
||||
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||
<TableRow key={r.returnId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Warehouse</p>
|
||||
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Reason</p>
|
||||
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">GL Journal</p>
|
||||
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{pagination && returns !== null && returns.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-48">Doc No</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>GL Journal</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{returns.map((r) => (
|
||||
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,19 +7,15 @@ import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateRequisitionLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateReqLineInput } from "@/types/procurement"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -44,7 +40,6 @@ function emptyLine(): DraftLine {
|
||||
|
||||
export default function NewRequisitionPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -52,25 +47,11 @@ export default function NewRequisitionPage() {
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
||||
useEffect(() => {
|
||||
if (branchingEnabled && isAdmin) {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}
|
||||
}, [branchingEnabled, isAdmin])
|
||||
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
@@ -86,10 +67,6 @@ export default function NewRequisitionPage() {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
if (branchingEnabled && !effectiveBranchId) {
|
||||
setSubmitError("Select a branch for this requisition.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
@@ -110,7 +87,7 @@ export default function NewRequisitionPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
|
||||
const requisition = await requisitionsApi.create({ lines: payloadLines })
|
||||
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
|
||||
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
|
||||
} catch (err) {
|
||||
@@ -140,30 +117,6 @@ export default function NewRequisitionPage() {
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{branchingEnabled && isAdmin && (
|
||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
||||
<Label className="text-base">Branch</Label>
|
||||
{showBranchPicker ? (
|
||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="All branches" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
|
||||
@@ -14,9 +14,8 @@ import { cn } from "@/lib/utils"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -24,7 +23,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = RequisitionStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function RequisitionsListPage() {
|
||||
const stats = useDashboardStats()
|
||||
@@ -78,21 +77,7 @@ export default function RequisitionsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow
|
||||
tiles={
|
||||
stats
|
||||
? [
|
||||
{
|
||||
label: "Open Requisitions",
|
||||
value: stats.openRequisitions,
|
||||
icon: Send,
|
||||
active: status === "Submitted",
|
||||
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
@@ -167,7 +152,29 @@ export default function RequisitionsListPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
@@ -17,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -36,10 +34,6 @@ function formatCost(value: number) {
|
||||
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
// The produced list comes back whole (no server paging), so pages are sliced client-side from
|
||||
// the already-loaded lines. The summary totals still cover the whole filtered list.
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
export default function ProducedItemsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -55,8 +49,6 @@ export default function ProducedItemsPage() {
|
||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
@@ -68,11 +60,7 @@ export default function ProducedItemsPage() {
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res)
|
||||
// A new filter is a new list — start back at the first page.
|
||||
setPage(1)
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err) => {
|
||||
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
||||
setLoadError(errorMessage(err))
|
||||
@@ -92,37 +80,26 @@ export default function ProducedItemsPage() {
|
||||
|
||||
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
||||
|
||||
const totalItems = data?.items.length ?? 0
|
||||
const pagination: PaginationMeta = {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
|
||||
}
|
||||
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two-up on phones (date range side by side, then template/warehouse), status full width —
|
||||
five stacked labelled controls would push the results well below the fold. */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">Template</Label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Template</Label>
|
||||
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
@@ -134,8 +111,8 @@ export default function ProducedItemsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">Warehouse</Label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
@@ -147,8 +124,8 @@ export default function ProducedItemsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
|
||||
<Label className="text-sm sm:text-base">Status</Label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Status</Label>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
@@ -198,148 +175,67 @@ export default function ProducedItemsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: totals first (the figure people come here for), then one card per line. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
|
||||
<p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
|
||||
<div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Produced</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Scrapped</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Good</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageItems.map((line) => (
|
||||
<button
|
||||
key={line.runId}
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{line.docNo}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Produced</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Scrapped</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Good</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit cost</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Cost of production</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
|
||||
{line.templateName} · {line.warehouseName}
|
||||
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ export function RunActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
|
||||
<Undo2 className="size-4" />
|
||||
Return leftover
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
|
||||
import {
|
||||
RunStageCardBody,
|
||||
RunHeaderNodeComponent,
|
||||
RunStageNodeComponent,
|
||||
type RunHeaderData,
|
||||
@@ -51,75 +50,7 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
|
||||
*/
|
||||
const HEADER_GAP_X = 240
|
||||
|
||||
/** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
|
||||
function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
|
||||
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
|
||||
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
|
||||
const actionable = ["InProgress", "Done", "Ready"] as const
|
||||
const activeId =
|
||||
run.status === "InProgress"
|
||||
? actionable.reduce<number | null>(
|
||||
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
|
||||
null
|
||||
)
|
||||
: null
|
||||
|
||||
return new Map(
|
||||
run.stages.map((stage) => {
|
||||
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
|
||||
return [
|
||||
stage.runStageId,
|
||||
{
|
||||
name: stage.name,
|
||||
roleLabel: stage.roleLabel,
|
||||
state: stage.status,
|
||||
isTerminal: stage.isTerminal,
|
||||
isEntry: stage.isEntry,
|
||||
estimatedMinutes: stage.estimatedMinutes,
|
||||
actualMinutes: stage.actualMinutes,
|
||||
actualStartAt: stage.actualStartAt,
|
||||
intake:
|
||||
upstream.length === 0
|
||||
? null
|
||||
: {
|
||||
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
|
||||
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
|
||||
},
|
||||
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
|
||||
isActive: stage.runStageId === activeId,
|
||||
} satisfies RunStageData,
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
|
||||
* (left to right, then top to bottom) so parallel branches read the way they were drawn.
|
||||
*/
|
||||
function flowOrder(run: ProductionRunGraph): RunStage[] {
|
||||
const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
|
||||
const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
|
||||
for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
|
||||
|
||||
const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
|
||||
const ordered: RunStage[] = []
|
||||
while (ready.length > 0) {
|
||||
ready.sort(byPosition)
|
||||
const stage = ready.shift()!
|
||||
ordered.push(stage)
|
||||
for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
|
||||
const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
|
||||
inDegree.set(e.childRunStageId, next)
|
||||
if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
|
||||
}
|
||||
}
|
||||
// The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
|
||||
const seen = new Set(ordered.map((s) => s.runStageId))
|
||||
return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
|
||||
}
|
||||
|
||||
function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>): { nodes: Node[]; edges: Edge[] } {
|
||||
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
|
||||
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
|
||||
|
||||
@@ -133,12 +64,42 @@ function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>
|
||||
},
|
||||
]
|
||||
|
||||
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
|
||||
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
|
||||
const actionable = ["InProgress", "Done", "Ready"] as const
|
||||
const activeId =
|
||||
run.status === "InProgress"
|
||||
? actionable.reduce<number | null>(
|
||||
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
|
||||
null
|
||||
)
|
||||
: null
|
||||
|
||||
for (const stage of run.stages) {
|
||||
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
|
||||
nodes.push({
|
||||
id: String(stage.runStageId),
|
||||
type: "runStage",
|
||||
position: { x: stage.posX, y: stage.posY },
|
||||
data: stageData.get(stage.runStageId)!,
|
||||
data: {
|
||||
name: stage.name,
|
||||
roleLabel: stage.roleLabel,
|
||||
state: stage.status,
|
||||
isTerminal: stage.isTerminal,
|
||||
isEntry: stage.isEntry,
|
||||
estimatedMinutes: stage.estimatedMinutes,
|
||||
actualMinutes: stage.actualMinutes,
|
||||
actualStartAt: stage.actualStartAt,
|
||||
intake:
|
||||
upstream.length === 0
|
||||
? null
|
||||
: {
|
||||
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
|
||||
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
|
||||
},
|
||||
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
|
||||
isActive: stage.runStageId === activeId,
|
||||
} satisfies RunStageData,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
@@ -212,12 +173,10 @@ export default function ProductionRunDetailPage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
|
||||
const { nodes, edges } = useMemo(
|
||||
() => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
|
||||
[run, stageData]
|
||||
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
|
||||
[run]
|
||||
)
|
||||
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
|
||||
|
||||
const stageSummary: StageSummary = useMemo(() => {
|
||||
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
|
||||
@@ -254,9 +213,9 @@ export default function ProductionRunDetailPage() {
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-full max-w-64" />
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
|
||||
<Skeleton className="h-[45vh] w-full rounded-2xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -333,38 +292,9 @@ export default function ProductionRunDetailPage() {
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="md:hidden">Tap a stage to open it.</span>
|
||||
<span className="hidden md:inline">Click a stage to open it.</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
|
||||
|
||||
{/* Phones get the stages as a list in flow order — the operator on the floor needs to
|
||||
reach "their" stage in one tap, not pan and pinch around a canvas. */}
|
||||
<ol className="flex flex-col gap-3 md:hidden">
|
||||
{orderedStages.map((stage, i) => {
|
||||
const data = stageData.get(stage.runStageId)!
|
||||
return (
|
||||
<li key={stage.runStageId} className="flex gap-3">
|
||||
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStageId(stage.runStageId)}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
|
||||
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
|
||||
)}
|
||||
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
|
||||
>
|
||||
<RunStageCardBody data={data} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
|
||||
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user