Compare commits

...

41 Commits

Author SHA1 Message Date
ImanThiyanga dd82f5b5c4 Merge branch 'Dev' into sales-changes 2026-09-25 09:03:15 +00:00
ImanThiyanga 268b6562dc Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m50s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m24s
2026-09-25 06:58:48 +00:00
ImanThiyanga b389c01dfe fron build failure fix 2026-09-25 12:28:20 +05:30
Sasanka d24072e20a fixing recipts in sales 2026-09-24 17:02:33 +05:30
ashan_rusiru 9185d39c39 Merge pull request 'Dev' (#88) from Dev into production
Reviewed-on: #88
2026-09-24 07:12:59 +00:00
ashan_rusiru a2790c13bb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m18s
2026-09-24 07:12:52 +00:00
ashan_rusiru 940583552a Merge pull request 'Ui fixers and responsivenes' (#87) from ui-fixers-and-responsivenes into Dev
Reviewed-on: #87
2026-09-24 07:09:52 +00:00
Sasanka 049c98298a Ui fixings 2026-09-24 12:30:21 +05:30
Sasanka f5e8e0e5b9 responsiveness and ui fixers 2026-09-23 16:55:46 +05:30
ashan_rusiru f35439e364 Merge pull request 'Dev' (#86) from Dev into production
Reviewed-on: #86
2026-09-21 19:03:14 +00:00
ashan_rusiru 4c5e9b347a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m41s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m5s
2026-09-21 19:03:08 +00:00
Dhananjaya99 fdd104284e feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
2026-09-22 00:32:40 +05:30
ImanThiyanga 72819e5f7b production change 2026-09-21 18:02:55 +05:30
kalana_Thilakarathna edd1011330 Merge pull request 'chore: update next.js version to ^16.3.5 in package.json' (#85) from Dev into production
Reviewed-on: #85
2026-09-20 12:41:34 +00:00
Kalana B. Thilakarathna 3ae14fad72 chore: update next.js version to ^16.3.5 in package.json
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m45s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m23s
2026-09-20 18:11:04 +05:30
kalana_Thilakarathna 84d3318f76 Merge pull request 'Dev' (#84) from Dev into production
Reviewed-on: #84
2026-09-20 12:27:45 +00:00
kalana_Thilakarathna bfb8e83a82 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m9s
2026-09-20 12:27:31 +00:00
kalana_Thilakarathna 40c1d6da27 Update Frontend/erp-system/Dockerfile 2026-09-20 12:26:57 +00:00
kalana_Thilakarathna 5aa709db17 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#83) from Dev into production
Reviewed-on: #83
2026-09-20 12:03:21 +00:00
kalana_Thilakarathna cf964a19a9 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m59s
2026-09-20 12:03:07 +00:00
kalana_Thilakarathna a40c6bf598 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#82) from Dev into production
Reviewed-on: #82
2026-09-20 11:57:04 +00:00
kalana_Thilakarathna f951700b58 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m16s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m55s
2026-09-20 11:56:50 +00:00
kalana_Thilakarathna b3939e0685 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#81) from Dev into production
Reviewed-on: #81
2026-09-20 11:50:14 +00:00
kalana_Thilakarathna af6f02023d Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m43s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-09-20 11:49:55 +00:00
kalana_Thilakarathna 9d569a1504 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#80) from Dev into production
Reviewed-on: #80
2026-09-20 11:41:23 +00:00
kalana_Thilakarathna 5a44702cf3 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m56s
2026-09-20 11:41:10 +00:00
kalana_Thilakarathna 2a5dee3fef Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#79) from Dev into production
Reviewed-on: #79
2026-09-20 11:34:43 +00:00
kalana_Thilakarathna 2584ab8715 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m8s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m36s
2026-09-20 11:34:29 +00:00
ImanThiyanga 6b9d191bf8 Merge pull request 'fixes on ci-cd' (#78) from Dev into production
Reviewed-on: #78
2026-09-18 19:32:33 +00:00
ImanThiyanga 283fa21e87 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 10m46s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m22s
2026-09-18 19:32:25 +00:00
ImanThiyanga 97d19d83ab fixes on ci-cd 2026-09-19 01:01:30 +05:30
ImanThiyanga 01d5159d27 Merge pull request 'changed front needs' (#77) from Dev into production
Reviewed-on: #77
2026-09-18 19:21:39 +00:00
ImanThiyanga 80899ed88c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 10m18s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m40s
2026-09-18 19:21:32 +00:00
ImanThiyanga cfe2466fec changed front needs 2026-09-19 00:51:11 +05:30
ImanThiyanga 9a124c8c63 Merge pull request 'fixed ci-cd' (#76) from Dev into production
Reviewed-on: #76
2026-09-18 19:05:55 +00:00
ImanThiyanga 42ab7e5964 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 19:05:48 +00:00
ImanThiyanga e7735135ca fixed ci-cd 2026-09-19 00:35:05 +05:30
ImanThiyanga f61d1f95b8 Merge pull request 'Dev' (#75) from Dev into production
Reviewed-on: #75
2026-09-18 18:54:54 +00:00
ImanThiyanga f632c5fc99 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 15m0s
2026-09-18 18:54:46 +00:00
ImanThiyanga 70179e1abc Merge pull request 'new updates' (#74) from new-updates into Dev
Reviewed-on: #74
2026-09-18 18:53:13 +00:00
ImanThiyanga 7274b89b28 Merge pull request 'migrations fix' (#72) from Dev into production
Reviewed-on: #72
2026-09-18 18:23:44 +00:00
143 changed files with 13330 additions and 5401 deletions
+25 -39
View File
@@ -1,4 +1,3 @@
# test
name: CI/CD - ERP Core Monorepo name: CI/CD - ERP Core Monorepo
on: on:
@@ -9,8 +8,8 @@ on:
- closed - closed
jobs: jobs:
backend-build-and-deploy: build-and-deploy-backend:
name: Build & Deploy ERP Core Backend name: Build & Deploy Backend
runs-on: linux runs-on: linux
if: github.event.pull_request.merged == true if: github.event.pull_request.merged == true
steps: steps:
@@ -21,69 +20,58 @@ jobs:
--branch production \ --branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \ https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
. .
- name: Restore
- name: Restore backend run: dotnet restore Backend/ERPCore.sln
working-directory: .gitea/workflows - name: Publish
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
run: | run: |
rm -rf /tmp/erpcore-deploy rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \ dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \ -c Release \
-r linux-x64 \ -r linux-x64 \
--self-contained true \ --self-contained true \
-o /tmp/erpcore-deploy -o /tmp/erpcore-deploy
- name: Stop service
- name: Stop backend service
run: systemctl stop erpcore.service run: systemctl stop erpcore.service
- name: Copy to deploy folder
- name: Copy backend to deploy folder
run: | run: |
mkdir -p /home/erp/core/publish mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/* rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/ cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix ownership and permissions
- name: Fix backend ownership and permissions
run: | run: |
chown -R erpuser:erpuser /home/erp/core/publish chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} + find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} + find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
- name: Restart backend service
run: systemctl restart erpcore.service run: systemctl restart erpcore.service
- name: Verify
- name: Verify backend
run: | run: |
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed" systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
frontend-build-and-deploy: build-and-deploy-frontend:
name: Build & Deploy ERP Core Frontend name: Build & Deploy Frontend
runs-on: linux runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true if: github.event.pull_request.merged == true
steps: steps:
- name: Checkout code - name: Checkout code
run: | run: |
git clone \ git clone \
--depth 1 \ --depth 1 \
--branch Production \ --branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \ https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
. .
- name: Build Docker image
- name: Build frontend Docker image run: |
working-directory: .gitea/workflows docker build --progress=plain --no-cache \
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system -t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing frontend container - name: Stop & remove existing container
run: | run: |
docker stop erpcore-frontend 2>/dev/null || true docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true docker rm erpcore-frontend 2>/dev/null || true
- name: Start container
- name: Start frontend container
run: | run: |
docker run -d \ docker run -d \
--name erpcore-frontend \ --name erpcore-frontend \
@@ -96,11 +84,9 @@ jobs:
--cap-drop ALL \ --cap-drop ALL \
--security-opt no-new-privileges \ --security-opt no-new-privileges \
erpcore-frontend:latest erpcore-frontend:latest
- name: Remove old images
- name: Remove old frontend images
run: docker image prune -f run: docker image prune -f
- name: Verify
- name: Verify frontend
run: | run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \ docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed" | grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
@@ -30,6 +30,19 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct) CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, 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 /// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary> /// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")] [HttpGet("scan")]
@@ -29,8 +29,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? customerId, [FromQuery] int? customerId,
[FromQuery] int? warehouseId, [FromQuery] int? warehouseId,
[FromQuery] int? branchId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -23,7 +23,12 @@ public sealed record DashboardStatsDto(
int PendingCounts, int PendingCounts,
int OpenRfqs, int OpenRfqs,
int PendingLeaveRequests, int PendingLeaveRequests,
int OverdueSalesInvoices); int OverdueSalesInvoices,
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary> /// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed record GrnDto(
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo); int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto( public sealed record CreatedLayerDto(
@@ -0,0 +1,18 @@
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);
+48 -2
View File
@@ -26,6 +26,7 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<Rfq> _rfqs; private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<LeaveRequest> _leaveRequests; private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices; private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IReorderService _reorder; private readonly IReorderService _reorder;
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema // Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
@@ -33,11 +34,14 @@ public sealed class DashboardService : IDashboardService
// balance older than this many days from its invoice date. // balance older than this many days from its invoice date.
private const int OverdueAgeDays = 30; private const int OverdueAgeDays = 30;
// Monthly Purchases chart window: the current month plus the previous 11.
private const int ChartMonths = 12;
public DashboardService( public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs, IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices, IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IReorderService reorder) IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
{ {
_layers = layers; _layers = layers;
_grns = grns; _grns = grns;
@@ -47,6 +51,7 @@ public sealed class DashboardService : IDashboardService
_rfqs = rfqs; _rfqs = rfqs;
_leaveRequests = leaveRequests; _leaveRequests = leaveRequests;
_salesInvoices = salesInvoices; _salesInvoices = salesInvoices;
_purchaseInvoices = purchaseInvoices;
_reorder = reorder; _reorder = reorder;
} }
@@ -103,9 +108,50 @@ public sealed class DashboardService : IDashboardService
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)) var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems; .Pagination.TotalItems;
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
return new DashboardStatsDto( return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs, pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices); pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
}
/// <summary>
/// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last
/// <see cref="ChartMonths"/> months, oldest first. Months with neither are omitted, so an
/// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate,
/// sales by InvoiceDate.
/// </summary>
private async Task<IReadOnlyList<MonthlyPurchasesVsSalesDto>> GetMonthlyPurchasesVsSalesAsync(
int? branchId, IQueryable<SalesInvoice> salesInvoices, CancellationToken ct)
{
var now = DateTime.UtcNow;
var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1));
var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking();
if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId);
var purchases = await purchaseInvoices
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since)
.GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var sales = await salesInvoices
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since)
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
.ToListAsync(ct);
var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total);
var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total);
return Enumerable.Range(0, ChartMonths)
.Select(offset => since.AddMonths(offset))
.Select(m => new MonthlyPurchasesVsSalesDto(
$"{m.Year:D4}-{m.Month:D2}",
purchasesByMonth.GetValueOrDefault((m.Year, m.Month)),
salesByMonth.GetValueOrDefault((m.Year, m.Month))))
.Where(m => m.Purchases != 0m || m.Sales != 0m)
.ToList();
} }
} }
+1 -1
View File
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null)) g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct); .ToListAsync(ct);
@@ -15,6 +15,12 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default); 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>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default); Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default); Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
+80
View File
@@ -1,4 +1,5 @@
using ERPCore.Common.Http; using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config; using ERPCore.Dtos.Config;
@@ -35,6 +36,7 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers; private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger; private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config; private readonly IProductConfigService _config;
@@ -52,6 +54,7 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses, IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers, IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger, IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches, IRepository<Batch> batches,
IRepository<Serial> serials, IRepository<Serial> serials,
IProductConfigService config, IProductConfigService config,
@@ -68,6 +71,7 @@ public sealed class ItemService : IItemService
_warehouses = warehouses; _warehouses = warehouses;
_stockLayers = stockLayers; _stockLayers = stockLayers;
_stockLedger = stockLedger; _stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches; _batches = batches;
_serials = serials; _serials = serials;
_config = config; _config = config;
@@ -108,6 +112,82 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total); 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) public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{ {
var item = await _items.Query().AsNoTracking() var item = await _items.Query().AsNoTracking()
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default) public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{ {
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines); IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -64,6 +64,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId. // Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId); if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
+1 -1
View File
@@ -3,6 +3,6 @@
"SigningKey": "" "SigningKey": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "" "BaseUrl": "http://localhost:6703"
} }
} }
+3 -2
View File
@@ -20,8 +20,9 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=6702 ENV PORT=6702
COPY package.json package-lock.json ./ COPY package.json ./
RUN npm ci --omit=dev && npm cache clean --force 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/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
@@ -0,0 +1,555 @@
"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&apos;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>
)
}
@@ -0,0 +1,548 @@
"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>
)
}
+98 -90
View File
@@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
HelpCircle, HelpCircle,
Inbox, Inbox,
@@ -33,12 +31,16 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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> = { const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary", Open: "border-transparent bg-primary/10 text-primary",
@@ -288,98 +290,104 @@ export default function HelpPage() {
)} )}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && ( {!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {pageItems.map((t) => (
<TableRow className="hover:bg-transparent"> <Link
<TableHead className="h-11 px-3 text-sm">Subject</TableHead> key={t.ticketId}
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>} href={`/dashboard/help/tickets/${t.ticketId}`}
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<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> <div className="flex items-start justify-between gap-2">
</TableRow> <p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
</TableHeader> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
<TableBody> {statusLabels[t.status]}
{pageItems.map((t) => ( </Badge>
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20"> </div>
<TableCell className="px-3 py-4"> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<Link {isAdmin && (
href={`/dashboard/help/tickets/${t.ticketId}`} <div>
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" <p className="text-muted-foreground">Submitted By</p>
> <p className="font-medium text-foreground">{t.createdByName}</p>
{t.subject} </div>
</Link> )}
</TableCell> <div>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>} <p className="text-muted-foreground">Messages</p>
<TableCell className="px-3 py-4"> <p className="font-medium text-foreground">{t.messageCount}</p>
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}> </div>
{statusLabels[t.status]} <div>
</Badge> <p className="text-muted-foreground">Last Updated</p>
</TableCell> <p className="font-medium text-foreground">
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell> {new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
<TableCell className="px-3 py-4 text-sm text-muted-foreground"> </p>
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { </div>
year: "numeric", </div>
month: "short", </Link>
day: "numeric", ))}
})}
</TableCell> {totalItems > pageItems.length && (
</TableRow> <Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
))} Show more
</TableBody> </Button>
</Table> )}
</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-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>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{totalItems > 0 && ( {totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page, pageSize, totalItems, totalPages }}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{totalItems}</span> tickets onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
{isAdmin ? ( {isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}> <Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Open">Open</SelectItem> <SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem> <SelectItem value="InProgress">In Progress</SelectItem>
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
{ticket.messages.map((m) => ( {ticket.messages.map((m) => (
<div <div
key={m.messageId} key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`} className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
> >
<div className="mb-1 flex items-center justify-between gap-2"> <div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>} {m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span> </span>
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"} placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3} rows={3}
/> />
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}> <Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"} {sending ? "Sending…" : "Send"}
</Button> </Button>
</div> </div>
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1> <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> <p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge> <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 === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>} {batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
@@ -148,47 +148,95 @@ export default function AttendanceBatchDetailPage() {
</div> </div>
)} )}
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {records.map((r) => (
<TableHead className="h-12 px-3 text-sm">Employee</TableHead> <div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Date</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead> <div className="min-w-0">
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead> <p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
<TableHead className="h-12 px-3 text-sm">Late</TableHead> <p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
<TableHead className="h-12 px-3 text-sm">OT</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <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>
<TableHead className="h-12 px-3 text-sm">Row</TableHead> </div>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>} <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <div>
</TableHeader> <p className="text-muted-foreground">In / Out</p>
<TableBody> <p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
{records.map((r) => ( </div>
<TableRow key={r.attendanceRecordId}> <div>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell> <p className="text-muted-foreground">Working Hours</p>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell> <p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell> </div>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell> <div>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell> <p className="text-muted-foreground">Late</p>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell> <p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell> </div>
<TableCell className="px-3 py-3.5"> <div>
<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> <p className="text-muted-foreground">OT</p>
</TableCell> <p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
{!locked && ( </div>
<TableCell className="px-3 py-3.5"> <div className="col-span-2">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && ( <p className="text-muted-foreground">Row status</p>
<div className="flex gap-1"> <p className="font-medium text-foreground">{r.attendanceStatus}</p>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button> </div>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button> </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>
)} )}
</TableCell> </TableRow>
)} ))}
</TableRow> </TableBody>
))} </Table>
</TableBody> </div>
</Table> </div>
</div> </div>
) )
} }
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useRouter } from "next/navigation"
import { Download, Eye, Plus, Upload } from "lucide-react" import { Download, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -21,6 +21,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25 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 STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = { const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -30,10 +33,13 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
} }
export default function AttendanceBatchesPage() { export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null) const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("") const [periodStart, setPeriodStart] = useState("")
@@ -43,11 +49,17 @@ export default function AttendanceBatchesPage() {
function load() { function load() {
attendanceApi attendanceApi
.list({ page, pageSize: PAGE_SIZE }) .list({ page, pageSize })
.then((res) => { setBatches(res.items); setPagination(res.pagination) }) .then((res) => { setBatches(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize])
async function handleUpload() { async function handleUpload() {
if (!file) { toast.error("Choose an Excel or CSV file first"); return } if (!file) { toast.error("Choose an Excel or CSV file first"); return }
@@ -75,12 +87,12 @@ export default function AttendanceBatchesPage() {
<h1 className="text-2xl font-bold text-foreground">Attendance</h1> <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&apos;s source of truth.</p> <p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}> <a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<Download className="size-4" />Download template <Download className="size-4" />Download template
</a> </a>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Upload Attendance</DialogTitle> <DialogTitle>Upload Attendance</DialogTitle>
@@ -111,34 +123,85 @@ 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> <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 && ( {!error && batches !== null && batches.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {batches.map((b) => (
<TableRow key={b.attendanceUploadBatchId}> <div
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell> key={b.attendanceUploadBatchId}
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell> role="link"
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell> tabIndex={0}
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell> onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
<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> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
<TableCell className="px-3 py-3.5"> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link> >
</TableCell> <div className="flex items-start justify-between gap-2">
</TableRow> <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>
))} ))}
</TableBody>
</Table> {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>
</>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)} )}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</div> </div>
) )
} }
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useParams, useSearchParams } from "next/navigation" import { useParams, useSearchParams } from "next/navigation"
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react" import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
@@ -44,6 +44,14 @@ function money(n: number): string {
} }
export default function EmployeeDetailPage() { export default function EmployeeDetailPage() {
return (
<Suspense>
<EmployeeDetailPageContent />
</Suspense>
)
}
function EmployeeDetailPageContent() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const employeeId = Number(params.id) const employeeId = Number(params.id)
@@ -327,7 +335,7 @@ export default function EmployeeDetailPage() {
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
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")} 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")}
> >
{t} {t}
</button> </button>
@@ -428,12 +436,12 @@ export default function EmployeeDetailPage() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -452,12 +460,12 @@ export default function EmployeeDetailPage() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -485,7 +493,7 @@ export default function EmployeeDetailPage() {
</Field> </Field>
<label <label
className={cn( className={cn(
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium", "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",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60" uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)} )}
title={uploadTypeId ? undefined : "Select a document type first"} title={uploadTypeId ? undefined : "Select a document type first"}
@@ -502,65 +510,105 @@ export default function EmployeeDetailPage() {
</label> </label>
</div> </div>
</section> </section>
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10"> {documents.length === 0 ? (
<div className="overflow-x-auto"> <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>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">File</TableHead> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20"> <div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell> <div className="min-w-0">
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell> <p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
<TableCell className="px-4 py-3.5"> <p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs", "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)} )}
> >
{d.status} {d.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-4 py-3.5"> <div className="flex items-center justify-between border-t pt-3 text-sm">
<div className="flex justify-center"> <span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
<a <a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
aria-label={`Download ${d.originalFileName}`} aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`} title={`Download ${d.originalFileName}`}
> >
<Download className="size-4" /> <Download className="size-4" />
</a> </a>
</div> </div>
</TableCell> </div>
</TableRow>
))} ))}
{documents.length === 0 && ( </div>
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <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>
</>
)}
</div> </div>
)} )}
{tab === "Salary & Loans" && ( {tab === "Salary & Loans" && (
<div className="flex flex-col gap-6"> <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"> <section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2> <h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}> <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>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
@@ -644,37 +692,63 @@ export default function EmployeeDetailPage() {
</div> </div>
)} )}
<div className="overflow-hidden rounded-xl border"> {salaryStructures.length === 0 ? (
<div className="overflow-x-auto"> <div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20"> <div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell> <div className="flex items-center justify-between">
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell> <span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell> <span className="text-muted-foreground">{s.status}</span>
<TableCell className="px-4 py-3.5">{s.status}</TableCell> </div>
</TableRow> <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>
))} ))}
{salaryStructures.length === 0 && ( </div>
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-xl border md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <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>
</>
)}
</section> </section>
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10"> <section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2> <h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}> <Dialog open={loanOpen} onOpenChange={setLoanOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
@@ -710,35 +784,61 @@ export default function EmployeeDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="overflow-hidden rounded-xl border"> {loans.length === 0 ? (
<div className="overflow-x-auto"> <div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20"> <div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell> <div className="flex items-center justify-between">
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell> <span className="font-medium text-foreground">{l.docNo}</span>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell> <span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell> </div>
<TableCell className="px-4 py-3.5">{l.status}</TableCell> <div className="grid grid-cols-2 gap-2 border-t pt-2">
</TableRow> <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>
))} ))}
{loans.length === 0 && ( </div>
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-xl border md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <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>
</>
)}
</section> </section>
</div> </div>
)} )}
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
</section> </section>
)} )}
<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"> <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" }))}> <Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button onClick={handleCreate} disabled={submitting} className="min-w-40"> <Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-4" /> <Save className="size-4" />
{submitting ? "Creating…" : "Create employee"} {submitting ? "Creating…" : "Create employee"}
</Button> </Button>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react" import { useRouter } from "next/navigation"
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -17,6 +18,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -27,8 +29,12 @@ import { toast } from "@/components/ui/toast"
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"] const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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() { export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null) const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -40,6 +46,7 @@ export default function EmployeesPage() {
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | 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) const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
@@ -63,6 +70,12 @@ export default function EmployeesPage() {
}) })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) }) .then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId]) useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
@@ -261,159 +274,240 @@ export default function EmployeesPage() {
)} )}
{!error && employees !== null && employees.length > 0 && ( {!error && employees !== null && employees.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {employees.map((e) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead> key={e.employeeId}
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead> role="link"
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead> tabIndex={0}
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead> onClick={(ev) => {
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead> if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead> router.push(`/dashboard/hrm/employees/${e.employeeId}`)
<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> onKeyDown={(ev) => {
</TableRow> if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
</TableHeader> }}
<TableBody> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
{employees.map((e) => ( >
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20"> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-4"> <div className="min-w-0">
<Link <p className="truncate font-semibold text-foreground">{e.fullName}</p>
href={`/dashboard/hrm/employees/${e.employeeId}`} <span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
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" </div>
> <Badge
{e.fullName} variant="outline"
</Link> className={cn(
</TableCell> "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>
<TableCell className="px-3 py-4"> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span> <div>
</TableCell> <p className="text-muted-foreground">Department</p>
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell> </div>
<div>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell> <p className="text-muted-foreground">Designation</p>
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
<TableCell className="px-3 py-4 text-sm text-muted-foreground"> </div>
<div>
<p className="text-muted-foreground">Hire Date</p>
<p className="font-medium text-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, { {new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</TableCell> </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 className="px-3 py-4"> <div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
{e.hasUserLink ? ( <Link
<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> href={`/dashboard/hrm/employees/${e.employeeId}`}
) : ( className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<span className="text-xs text-muted-foreground">No login</span> aria-label={`View ${e.fullName}`}
)} title={`View ${e.fullName}`}
</TableCell> >
<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"> {pagination && employees.length < pagination.totalItems && (
<Badge <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
variant="outline" {showMoreLoading ? "Loading…" : "Show more"}
className={cn( </Button>
"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">
<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>
</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>
<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>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react" import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -26,6 +26,9 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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 STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -48,6 +51,7 @@ export default function LeaveRequestsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
@@ -77,6 +81,12 @@ export default function LeaveRequestsPage() {
}) })
.then((res) => { setRequests(res.items); setPagination(res.pagination) }) .then((res) => { setRequests(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -304,124 +314,155 @@ export default function LeaveRequestsPage() {
)} )}
{!error && requests !== null && requests.length > 0 && ( {!error && requests !== null && requests.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {requests.map((r) => (
<TableRow className="hover:bg-transparent"> <div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead> <div className="min-w-0">
<TableHead className="h-11 px-4 text-sm">Type</TableHead> <p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead> <span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
<TableHead className="h-11 px-4 text-sm">Days</TableHead> </div>
<TableHead className="h-11 px-4 text-sm">Status</TableHead> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead> {r.status}
</TableRow> </Badge>
</TableHeader> </div>
<TableBody>
{requests.map((r) => ( <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20"> <div>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell> <p className="text-muted-foreground">Type</p>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell> <p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> <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">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()} {new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</TableCell> </p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell> </div>
<TableCell className="px-4 py-4"> </div>
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status} {r.status === "Submitted" && (
</Badge> <div className="flex items-center justify-end gap-1 border-t pt-3">
</TableCell> <Button
<TableCell className="px-4 py-4"> type="button"
{r.status === "Submitted" ? ( variant="ghost"
<div className="flex items-center justify-center gap-1"> size="icon-sm"
<Button className="text-success hover:bg-success/10"
type="button" aria-label={`Approve ${r.docNo}`}
variant="ghost" title={`Approve ${r.docNo}`}
size="icon-sm" disabled={actionPendingId === r.leaveRequestId}
className="text-success hover:bg-success/10" onClick={() => approve(r.leaveRequestId)}
aria-label={`Approve ${r.docNo}`} >
title={`Approve ${r.docNo}`} <CheckCircle2 className="size-4" />
disabled={actionPendingId === r.leaveRequestId} </Button>
onClick={() => approve(r.leaveRequestId)} <Button
> type="button"
<CheckCircle2 className="size-4" /> variant="ghost"
</Button> size="icon-sm"
<Button className="text-destructive hover:bg-destructive/10"
type="button" aria-label={`Reject ${r.docNo}`}
variant="ghost" title={`Reject ${r.docNo}`}
size="icon-sm" disabled={actionPendingId === r.leaveRequestId}
className="text-destructive hover:bg-destructive/10" onClick={() => openReject(r)}
aria-label={`Reject ${r.docNo}`} >
title={`Reject ${r.docNo}`} <XCircle className="size-4" />
disabled={actionPendingId === r.leaveRequestId} </Button>
onClick={() => openReject(r)} </div>
> )}
<XCircle className="size-4" /> </div>
</Button> ))}
</div>
) : ( {pagination && requests.length < pagination.totalItems && (
<span className="flex justify-center text-xs text-muted-foreground">—</span> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
)} {showMoreLoading ? "Loading…" : "Show more"}
</TableCell> </Button>
</TableRow> )}
))}
</TableBody>
</Table>
</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">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()}
</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>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
</div> </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="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 items-start justify-between gap-4 border-b border-border pb-5"> <div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1> <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> <p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
</div> </div>
<div className="text-right"> <div className="sm:text-right">
<p className="text-sm text-muted-foreground">Period</p> <p className="text-sm text-muted-foreground">Period</p>
<p className="font-semibold text-foreground">{period}</p> <p className="font-semibold text-foreground">{period}</p>
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p> <p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
@@ -1,9 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useParams, useRouter } from "next/navigation"
import { useParams } from "next/navigation" import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -15,18 +14,38 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function money(n: number): string { function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) 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() { export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id) const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null) const [run, setRun] = useState<PayrollRun | null>(null)
@@ -136,11 +155,11 @@ export default function PayrollRunDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-3 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> <StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<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> <StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<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> <StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
<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> <StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -177,114 +196,146 @@ export default function PayrollRunDetailPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {pagedLines.map((l) => {
<TableRow className="hover:bg-transparent"> const totalDeductions = l.grossSalary - l.netSalary
<TableHead className="h-11 px-4 text-sm">Employee</TableHead> const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
<TableHead className="h-11 px-4 text-sm">Basic</TableHead> return (
<TableHead className="h-11 px-4 text-sm">OT</TableHead> <div
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead> key={l.payrollLineId}
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead> role="link"
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead> tabIndex={0}
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead> onClick={(e) => {
</TableRow> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableHeader> router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
<TableBody> }}
{pagedLines.map((l) => { onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
const totalDeductions = l.grossSalary - l.netSalary className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) >
return ( <div className="flex items-start justify-between gap-2">
<TableRow key={l.payrollLineId} className="hover:bg-muted/20"> <div className="min-w-0">
<TableCell className="px-4 py-3.5 font-medium text-foreground"> <p className="truncate font-semibold text-foreground">{l.employeeName}</p>
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span> <span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
</TableCell> </div>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell> {payslip && (
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell> <a
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell> href={payslipViewUrl(payslip.payslipId)}
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell> target="_blank"
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell> rel="noreferrer"
<TableCell className="px-4 py-3.5"> data-no-row-nav
<div className="flex items-center justify-center gap-1"> className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
<Link aria-label={`View payslip for ${l.employeeName}`}
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} title="View payslip"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} >
aria-label={`View breakdown for ${l.employeeName}`} <FileText className="size-4" />
title="View breakdown" </a>
> )}
<Eye className="size-4" /> </div>
</Link> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{payslip && ( <div>
<a <p className="text-muted-foreground">Basic</p>
href={payslipViewUrl(payslip.payslipId)} <p className="font-medium text-foreground">{money(l.basicSalary)}</p>
target="_blank" </div>
rel="noreferrer" <div>
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <p className="text-muted-foreground">OT</p>
aria-label={`View payslip for ${l.employeeName}`} <p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
title="View payslip" </div>
> <div>
<FileText className="size-4" /> <p className="text-muted-foreground">Allowances</p>
</a> <p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
)} </div>
</div> <div>
</TableCell> <p className="text-muted-foreground">Deductions</p>
</TableRow> <p className="font-medium text-foreground">{money(totalDeductions)}</p>
) </div>
})} <div className="col-span-2">
</TableBody> <p className="text-muted-foreground">Net Salary</p>
</Table> <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> </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">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>
</>
)} )}
{filteredLines.length > 0 && ( {filteredLines.length > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react" import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll" import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -10,17 +10,21 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm" import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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 STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -31,12 +35,14 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
} }
export default function PayrollRunsPage() { export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null) const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
const [statusFilter, setStatusFilter] = useState(ALL_STATUS) const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const now = new Date() const now = new Date()
@@ -49,6 +55,12 @@ export default function PayrollRunsPage() {
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) }) .list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
.then((res) => { setRuns(res.items); setPagination(res.pagination) }) .then((res) => { setRuns(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -141,102 +153,99 @@ export default function PayrollRunsPage() {
)} )}
{!error && runs !== null && runs.length > 0 && ( {!error && runs !== null && runs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {runs.map((r) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> key={r.payrollRunId}
<TableHead className="h-11 px-4 text-sm">Period</TableHead> role="link"
<TableHead className="h-11 px-4 text-sm">Employees</TableHead> tabIndex={0}
<TableHead className="h-11 px-4 text-sm">Gross</TableHead> onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
<TableHead className="h-11 px-4 text-sm">Net</TableHead> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
<TableHead className="h-11 px-4 text-sm">Status</TableHead> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead> >
</TableRow> <div className="flex items-start justify-between gap-2">
</TableHeader> <div className="min-w-0">
<TableBody> <p className="truncate font-semibold text-foreground">{r.docNo}</p>
{runs.map((r) => ( <p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
<TableRow key={r.payrollRunId} className="hover:bg-muted/20"> </div>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell> {r.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell> </Badge>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableCell className="px-4 py-4"> <div>
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}> <p className="text-muted-foreground">Employees</p>
{r.status} <p className="font-medium text-foreground">{r.employeeCount}</p>
</Badge> </div>
</TableCell> <div>
<TableCell className="px-4 py-4"> <p className="text-muted-foreground">Gross</p>
<div className="flex items-center justify-center"> <p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
<Link </div>
href={`/dashboard/hrm/payroll/${r.payrollRunId}`} <div>
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <p className="text-muted-foreground">Net</p>
aria-label={`View ${r.docNo}`} <p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
title={`View ${r.docNo}`} </div>
> </div>
<Eye className="size-4" /> </div>
</Link> ))}
</div>
</TableCell> {pagination && runs.length < pagination.totalItems && (
</TableRow> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
))} {showMoreLoading ? "Loading…" : "Show more"}
</TableBody> </Button>
</Table> )}
</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">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>
</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters" import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__" 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() { export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null) const [items, setItems] = useState<Department[] | null>(null)
@@ -34,6 +37,7 @@ export default function DepartmentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function DepartmentsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -285,9 +295,9 @@ export default function DepartmentsPage() {
</Select> </Select>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -356,151 +366,209 @@ export default function DepartmentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((d) => (
<TableRow className="hover:bg-transparent"> <div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead> <p className="truncate font-semibold text-foreground">{d.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{d.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((d) => ( d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<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> {d.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> </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">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} {items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<Badge <div>
variant="outline" <p className="text-muted-foreground">Created</p>
className={cn( <p className="font-medium text-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>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </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={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${d.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${d.name}`} aria-label={`Delete ${d.name}`}
onClick={() => openEdit(d)} title={`Delete ${d.name}`}
>
<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} disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)} />
> }
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${d.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(d)}
aria-label={`Delete ${d.name}`} />
title={`Delete ${d.name}`} </AlertDialog>
disabled={actionPendingId === d.departmentId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
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>
</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"
)}
>
{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}
/>
}
>
<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 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters" import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -24,6 +24,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] 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() { export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null) const [items, setItems] = useState<HrDocumentType[] | null>(null)
@@ -34,6 +37,7 @@ export default function DocumentTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -59,6 +63,12 @@ export default function DocumentTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -261,9 +271,9 @@ export default function DocumentTypesPage() {
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel> <FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -332,149 +342,205 @@ export default function DocumentTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((t) => (
<TableRow className="hover:bg-transparent"> <div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead> <p className="truncate font-semibold text-foreground">{t.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{t.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((t) => ( t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<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> {t.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Category</p>
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{t.category}</p>
)} </div>
> <div>
{t.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </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={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${t.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${t.name}`} aria-label={`Delete ${t.name}`}
onClick={() => openEdit(t)} title={`Delete ${t.name}`}
>
<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} disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)} />
> }
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${t.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(t)}
aria-label={`Delete ${t.name}`} />
title={`Delete ${t.name}`} </AlertDialog>
disabled={actionPendingId === t.hrDocumentTypeId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
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>
</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"
)}
>
{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}
/>
}
>
<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 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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() { export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null) const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -32,6 +35,7 @@ export default function LeaveTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function LeaveTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -279,9 +289,9 @@ export default function LeaveTypesPage() {
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel> <FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -351,151 +361,211 @@ export default function LeaveTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((t) => (
<TableRow className="hover:bg-transparent"> <div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead> <p className="truncate font-semibold text-foreground">{t.name}</p>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead> <span className="font-mono text-xs text-muted-foreground">{t.code}</span>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead> </div>
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead> <Badge
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead> variant="outline"
</TableRow> className={cn(
</TableHeader> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
<TableBody> t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
{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> {t.status}
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell> </Badge>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<Badge <div>
variant="outline" <p className="text-muted-foreground">Days/yr</p>
className={cn( <p className="font-medium text-foreground">{t.accrualPerYear}</p>
"h-5 w-fit justify-center bg-white px-2 text-xs", </div>
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <div>
)} <p className="text-muted-foreground">Paid</p>
> <p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
{t.status} </div>
</Badge> <div>
</TableCell> <p className="text-muted-foreground">Created</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> <p className="font-medium text-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </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={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${t.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${t.name}`} aria-label={`Delete ${t.name}`}
onClick={() => openEdit(t)} title={`Delete ${t.name}`}
>
<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} disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)} />
> }
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${t.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(t)}
aria-label={`Delete ${t.name}`} />
title={`Delete ${t.name}`} </AlertDialog>
disabled={actionPendingId === t.leaveTypeId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
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>
</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"
)}
>
{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}
/>
}
>
<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 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters" import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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() { export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null) const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -33,6 +36,7 @@ export default function SalaryComponentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -57,6 +61,12 @@ export default function SalaryComponentsPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -254,9 +264,9 @@ export default function SalaryComponentsPage() {
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel> <FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -325,149 +335,205 @@ export default function SalaryComponentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((c) => (
<TableRow className="hover:bg-transparent"> <div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead> <p className="truncate font-semibold text-foreground">{c.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{c.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((c) => ( c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<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> {c.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Type</p>
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{c.componentType}</p>
)} </div>
> <div>
{c.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </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={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${c.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${c.name}`} aria-label={`Delete ${c.name}`}
onClick={() => openEdit(c)} title={`Delete ${c.name}`}
>
<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} disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)} />
> }
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${c.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(c)}
aria-label={`Delete ${c.name}`} />
title={`Delete ${c.name}`} </AlertDialog>
disabled={actionPendingId === c.salaryComponentId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
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>
</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"
)}
>
{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}
/>
}
>
<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 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -141,34 +141,66 @@ export default function StatutorySettingsPage() {
)} )}
{settings !== null && settings.length > 0 && ( {settings !== null && settings.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {settings.map((s) => (
<TableRow className="hover:bg-transparent"> <div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <p className="font-semibold text-foreground">
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead> {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead> </p>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead> <div>
</TableRow> <p className="text-muted-foreground">EPF (Employee)</p>
</TableHeader> <p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
<TableBody> </div>
{settings.map((s) => ( <div>
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20"> <p className="text-muted-foreground">EPF (Employer)</p>
<TableCell className="px-4 py-4 text-sm text-foreground"> <p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} </div>
</TableCell> <div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell> <p className="text-muted-foreground">ETF (Employer)</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell> <p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell> <div>
</TableRow> <p className="text-muted-foreground">OT multiplier</p>
))} <p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
</TableBody> </div>
</Table> </div>
</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>
</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>
</div>
</>
)} )}
</section> </section>
@@ -225,28 +257,46 @@ export default function StatutorySettingsPage() {
)} )}
{slabs !== null && slabs.length > 0 && ( {slabs !== null && slabs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {slabs.map((s) => (
<TableRow className="hover:bg-transparent"> <div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <div className="flex items-center justify-between gap-2">
<TableHead className="h-11 px-4 text-sm">Range</TableHead> <p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead> <p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
</TableRow> </div>
</TableHeader> <p className="border-t pt-3 text-sm text-muted-foreground">
<TableBody> {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
{slabs.map((s) => ( </p>
<TableRow key={s.taxSlabId} className="hover:bg-muted/20"> </div>
<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> </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>
</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>
</div>
</>
)} )}
</section> </section>
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters" import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 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[] { function maskToDays(mask: number): boolean[] {
return DAYS.map((_, i) => (mask & (1 << i)) !== 0) return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
@@ -37,6 +40,7 @@ export default function WorkShiftsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -75,6 +79,12 @@ export default function WorkShiftsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .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 useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -218,7 +228,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} /> <DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle> <DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription> <DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
@@ -287,7 +297,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}> <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Edit Work Shift</DialogTitle> <DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription> <DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
@@ -356,9 +366,9 @@ export default function WorkShiftsPage() {
</div> </div>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -427,149 +437,205 @@ export default function WorkShiftsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((w) => (
<TableRow className="hover:bg-transparent"> <div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead> <p className="truncate font-semibold text-foreground">{w.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{w.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((w) => ( w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<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> {w.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Hours</p>
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
)} </div>
> <div>
{w.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </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={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${w.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${w.name}`} aria-label={`Delete ${w.name}`}
onClick={() => openEdit(w)} title={`Delete ${w.name}`}
>
<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} disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)} />
> }
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${w.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(w)}
aria-label={`Delete ${w.name}`} />
title={`Delete ${w.name}`} </AlertDialog>
disabled={actionPendingId === w.workShiftId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
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>
</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"
)}
>
{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}
/>
}
>
<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 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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>
)} )}
</div> </div>
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4"> <div className="mt-4">
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} /> <ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<TableHeader> <Table className="text-base">
<TableRow> <TableHeader>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead> <TableRow>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead> <TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead> <TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead> <TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead> <TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every {/* 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 — 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. */} lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows] {[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo)) .sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => ( .map((row, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell> <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">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</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">{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.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
</TableRow> ))}
))} </TableBody>
</TableBody> </Table>
</Table> </div>
</div> </div>
)} )}
</div> </div>
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader <ReportHeader
title="Statement of Profit or Loss" title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
)} )}
{!error && report !== null && ( {!error && report !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller {/* 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 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 — appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
@@ -97,36 +97,38 @@ export default function TrialBalancePage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<TableHeader> <Table className="text-base">
<TableRow> <TableHeader>
<TableHead className="h-11 px-3 text-sm">Account</TableHead> <TableRow>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead> <TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead> <TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
</TableRow> <TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent"> {rows.map((row, i) => (
<TableRow className="hover:bg-transparent"> <TableRow key={i}>
<TableCell className="px-3 py-3 font-bold">Total</TableCell> <TableCell className="px-3 py-2.5">
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell> <span className="text-muted-foreground">{row.accountCode}</span>{" "}
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell> <span>{row.accountName}</span>
</TableRow> </TableCell>
</TableFooter> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
</Table> <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>
</div> </div>
)} )}
</div> </div>
+102 -37
View File
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
AlertTriangle, AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
ClipboardList, ClipboardList,
@@ -168,7 +170,7 @@ export default function DashboardPage() {
{loaded ? ( {loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? ( (stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64"> <div className="h-64 sm:h-72">
<BarChart <BarChart
labels={stats.stockValuationByWarehouse.map( labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` (w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
@@ -186,7 +188,7 @@ export default function DashboardPage() {
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p> <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" /> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)} )}
</div> </div>
@@ -199,7 +201,7 @@ export default function DashboardPage() {
</div> </div>
{loaded ? ( {loaded ? (
<div className="h-52 sm:h-64"> <div className="h-64 sm:h-72">
<LineChart <LineChart
labels={movementTrend.labels} labels={movementTrend.labels}
datasets={[ datasets={[
@@ -209,7 +211,7 @@ export default function DashboardPage() {
/> />
</div> </div>
) : ( ) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" /> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)} )}
</div> </div>
</div> </div>
@@ -227,48 +229,111 @@ export default function DashboardPage() {
{loaded ? ( {loaded ? (
movements.length > 0 ? ( movements.length > 0 ? (
<Table> <>
<TableHeader> {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {movements.map((entry) => (
<TableRow key={entry.ledgerId}> <div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell> <div className="flex min-w-0 items-center gap-3">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} <div
</TableCell> className={cn(
<TableCell> "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>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-6 w-16 justify-center text-xs", "h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)} )}
> >
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </div>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{entry.sourceDocType} #{entry.sourceDocId} <div>
</TableCell> <p className="text-muted-foreground">Qty</p>
<TableCell className="text-right"> <p className="font-medium text-foreground">{entry.qtyBase}</p>
{new Date(entry.createdAt).toLocaleDateString()} </div>
</TableCell> <div>
</TableRow> <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>
))} ))}
</TableBody> </div>
</Table>
{/* 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>
</>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p> <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 ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" /> <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> <p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{invoice.status !== "Draft" && ( {invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" /> <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>} {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-6 shadow-sm"> <section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="border-b pb-4"> <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 className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div> <div>
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <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" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}> <Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"} {saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button> </Button>
</div> </div>
@@ -2,10 +2,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { useRouter } from "next/navigation"
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { dashboardApi } from "@/lib/api/dashboard"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -15,10 +17,12 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices" import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -33,6 +37,7 @@ const tabs: { value: StatusFilter; label: string }[] = [
] ]
export default function PurchaseInvoicesPage() { export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null) const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
@@ -45,6 +50,15 @@ export default function PurchaseInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) 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(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -140,6 +154,30 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</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="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-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -190,7 +228,99 @@ export default function PurchaseInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && ( {!error && rows !== null && visibleRows.length > 0 && (
<> <>
<div className="overflow-x-auto"> <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">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -209,7 +339,14 @@ export default function PurchaseInvoicesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleRows.map((row) => ( {visibleRows.map((row) => (
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40"> <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}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium"> <TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline"> <Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo} {row.docNo}
@@ -235,7 +372,7 @@ export default function PurchaseInvoicesPage() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5 text-right"> <TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -262,14 +399,6 @@ export default function PurchaseInvoicesPage() {
Pay Pay
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true) setSaving(true)
try { try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag) const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
setPo(result.data) setPo(result.data)
setEtag(result.etag) setEtag(result.etag)
setLines(toDraftLines(result.data)) setLines(toDraftLines(result.data))
@@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{po.status === "Draft" && ( {po.status === "Draft" && (
<> <>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" /> <Send className="size-5" />
{submitting ? "Submitting…" : "Submit for approval"} {submitting ? "Submitting…" : "Submit for approval"}
</Button> </Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />} render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
> >
<Trash2 className="size-5" /> <Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"} {deleting ? "Deleting…" : "Delete draft"}
@@ -348,11 +348,11 @@ export default function PurchaseOrderDetailPage() {
)} )}
{po.status === "PendingApproval" && isAdmin && ( {po.status === "PendingApproval" && isAdmin && (
<> <>
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" /> <Check className="size-5" />
{approving ? "Approving…" : "Approve"} {approving ? "Approving…" : "Approve"}
</Button> </Button>
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}> <Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<X className="size-5" /> <X className="size-5" />
Reject Reject
</Button> </Button>
@@ -362,6 +362,7 @@ export default function PurchaseOrderDetailPage() {
<Button <Button
variant="destructive" variant="destructive"
size="lg" size="lg"
className="w-full sm:w-auto"
onClick={() => setShowCancelForm(true)} onClick={() => setShowCancelForm(true)}
disabled={hasReceipts} disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined} title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
@@ -380,13 +381,13 @@ export default function PurchaseOrderDetailPage() {
value={cancelReason} value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)} onChange={(e) => setCancelReason(e.target.value)}
placeholder="Reason (e.g. Duplicate order)" placeholder="Reason (e.g. Duplicate order)"
className="h-11 max-w-md text-base" className="h-11 w-full text-base sm:max-w-md"
/> />
<div className="flex gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back Back
</Button> </Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}> <Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Confirm cancellation"} {cancelling ? "Cancelling…" : "Confirm cancellation"}
</Button> </Button>
</div> </div>
@@ -437,7 +438,7 @@ export default function PurchaseOrderDetailPage() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && ( {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 }])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
@@ -447,7 +448,148 @@ export default function PurchaseOrderDetailPage() {
)} )}
</div> </div>
<div className="overflow-x-auto"> {/* 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>
<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]} />
</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">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -574,11 +716,11 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
{editable && ( {editable && (
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}> <Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back Back
</Button> </Button>
<Button size="lg" onClick={handleSave} disabled={saving}> <Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react" import { useRouter } from "next/navigation"
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -34,6 +35,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
* still shows the queue to non-admins (read-only) so anyone can see what's waiting. * still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/ */
export default function PurchaseOrderApprovalsPage() { export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => { {pos.map((po) => {
const vendor = vendorsById.get(po.vendorId) const vendor = vendorsById.get(po.vendorId)
return ( return (
<TableRow key={po.poId} className="align-top"> <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}`)
}}
>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo} {po.docNo}
@@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()} {new Date(po.createdAt).toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
@@ -195,14 +204,6 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" /> <X className="size-4" />
Reject Reject
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -10,6 +10,7 @@ import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs" import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
@@ -19,7 +20,9 @@ import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code" import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" 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 { Button, buttonVariants } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -66,11 +69,14 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null const rfqVendorId = Number(searchParams.get("vendorId")) || null
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null) const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | 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 [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -211,6 +217,21 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId]) }, [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
function updateLine(key: string, patch: Partial<DraftLine>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
} }
@@ -231,6 +252,10 @@ function NewPurchaseOrderContent() {
setHeaderError("Select a vendor.") setHeaderError("Select a vendor.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this purchase order.")
return
}
if (lines.length === 0) { if (lines.length === 0) {
setSubmitError("Add at least one line.") setSubmitError("Add at least one line.")
return return
@@ -268,6 +293,7 @@ function NewPurchaseOrderContent() {
requisitionId: requisitionId ?? (rfqId ? undefined : null), requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines, lines: payloadLines,
saveAsDraft, saveAsDraft,
branchId: effectiveBranchId ?? 0,
}) })
toast.success( toast.success(
"Purchase order created", "Purchase order created",
@@ -369,6 +395,30 @@ function NewPurchaseOrderContent() {
</Dialog> </Dialog>
</div> </div>
</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 && ( {requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div> <div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
)} )}
@@ -380,9 +430,9 @@ function NewPurchaseOrderContent() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
href="/dashboard/products/new" href="/dashboard/products/new"
target="_blank" target="_blank"
@@ -401,27 +451,21 @@ function NewPurchaseOrderContent() {
</div> </div>
{lines.length > 0 && ( {lines.length > 0 && (
<div className="overflow-x-auto"> <>
<Table className="table-fixed text-base"> {/* Mobile: stacked editable cards, one per line. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {lines.map((line) => {
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead> const errors = lineErrors[line.key] ?? {}
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead> const item = itemFor(line.itemId)
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead> return (
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead> <div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead> <div className="flex items-start gap-2">
<TableHead className="h-12 w-10 px-3" /> <div className="flex-1">
</TableRow> <Label className="text-sm">Item</Label>
</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 ? ( {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>
) : ( ) : (
<> <>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
@@ -443,13 +487,28 @@ function NewPurchaseOrderContent() {
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</> </>
)} )}
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <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>
<div className="flex h-11 items-center text-base text-muted-foreground"> <div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)} {baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div> </div>
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <div>
<Label className="text-sm">Warehouse</Label>
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> <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}> <SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" /> <SelectValue placeholder="Warehouse" />
@@ -463,42 +522,144 @@ function NewPurchaseOrderContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} /> <FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
step="any" step="any"
inputMode="decimal"
value={line.qty} value={line.qty}
aria-invalid={!!errors.qty} aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })} onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} /> <FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <div>
<Label className="text-sm">Unit price</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
step="any" step="any"
inputMode="decimal"
value={line.unitPrice} value={line.unitPrice}
aria-invalid={!!errors.unitPrice} aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} /> <FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> </div>
<Trash2 className="size-5" /> )
</Button> })}
</TableCell> </div>
</TableRow>
) {/* Desktop: table. */}
})} <div className="hidden overflow-x-auto md:block">
</TableBody> <Table className="table-fixed text-base">
</Table> <TableHeader>
</div> <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>
) : (
<>
<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]} />
</>
)}
</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>
{(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]} />
</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>
</>
)} )}
</div> </div>
@@ -506,14 +667,14 @@ function NewPurchaseOrderContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <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" }))}> <Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}> <Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"} {submitting ? "Saving…" : "Save as draft"}
</Button> </Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"} {submitting ? "Creating…" : "Create & submit"}
</Button> </Button>
</div> </div>
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, 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 { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -29,9 +30,13 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25 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 PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats() const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -44,8 +49,10 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null) const [submittingId, setSubmittingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -57,15 +64,21 @@ export default function PurchaseOrdersListPage() {
function load() { function load() {
setError(null) setError(null)
purchaseOrdersApi purchaseOrdersApi
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) .list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => { .then((res) => {
setPos(res.items) setPos(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, query, status]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status])
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, []) }, [])
@@ -122,12 +135,15 @@ export default function PurchaseOrdersListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1> <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&apos;s issued (FR-PROC-03..05).</p> <p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <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" }))}> <Link
href="/dashboard/procurement/purchase-orders/approvals"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
>
<Clock className="size-5" /> <Clock className="size-5" />
Approvals queue Approvals queue
</Link> </Link>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New PO New PO
</Link> </Link>
@@ -171,9 +187,21 @@ export default function PurchaseOrdersListPage() {
)} )}
{!error && pos === null && ( {!error && pos === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -187,45 +215,171 @@ export default function PurchaseOrdersListPage() {
{!error && visiblePos !== null && visiblePos.length > 0 && ( {!error && visiblePos !== null && visiblePos.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {visiblePos.map((po) => {
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> const editable = isPoEditable(po.status)
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> return (
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <div
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead> key={po.poId}
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>} role="link"
<TableHead className="h-12 px-3 text-sm">Created</TableHead> tabIndex={0}
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead> onClick={(e) => {
</TableRow> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableHeader> 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>
</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>
{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>Status</TableHead>
<TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead>Created</TableHead>
<TableHead className="w-44 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{visiblePos.map((po) => { {visiblePos.map((po) => {
const editable = isPoEditable(po.status) const editable = isPoEditable(po.status)
return ( return (
<TableRow key={po.poId}> <TableRow
<TableCell className="px-3 py-3.5"> key={po.poId}
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> 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">
{po.docNo} {po.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge> <Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link <Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`} href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View" aria-label="View purchase order"
title="View" title="View"
> >
<Eye className="size-4" /> <Eye className="size-4" />
@@ -297,8 +451,19 @@ export default function PurchaseOrdersListPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -210,49 +210,99 @@ function NewPurchaseReturnContent() {
{selectedGrn && ( {selectedGrn && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2> <h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
<Table className="text-base">
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 w-10 px-3" /> {selectedGrn.lines.map((line) => {
<TableHead className="h-12 px-3 text-sm">Item</TableHead> const item = itemFor(line.itemId)
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead> const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead> const errors = lineErrors[line.grnLineId] ?? {}
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead> return (
</TableRow> <div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
</TableHeader> <label className="flex items-start gap-3">
<TableBody> <Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
{selectedGrn.lines.map((line) => { <span className="min-w-0 truncate text-base font-medium text-foreground">
const item = itemFor(line.itemId) {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } </span>
const errors = lineErrors[line.grnLineId] ?? {} </label>
return (
<TableRow key={line.grnLineId}> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5"> <div>
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} /> <p className="text-muted-foreground">Received qty</p>
</TableCell> <p className="font-medium text-foreground">{line.qty}</p>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell> </div>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell> <div>
<TableCell className="px-3 py-3.5"> <p className="text-muted-foreground">Hold status</p>
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<Input
type="number" <div>
min="0" <Label className="text-sm">Return qty</Label>
step="any" <Input
value={state.qty} type="number"
disabled={!state.selected} min="0"
aria-invalid={!!errors.qty} step="any"
onChange={(e) => setQty(line.grnLineId, e.target.value)} inputMode="decimal"
className="h-11 text-base" value={state.qty}
/> disabled={!state.selected}
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} /> aria-invalid={!!errors.qty}
</TableCell> onChange={(e) => setQty(line.grnLineId, e.target.value)}
</TableRow> className="h-11 text-base"
) />
})} <FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableBody> </div>
</Table> </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>
</div> </div>
)} )}
@@ -260,11 +310,11 @@ function NewPurchaseReturnContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <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" }))}> <Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"} {submitting ? "Posting…" : "Post Return"}
</Button> </Button>
</div> </div>
@@ -9,31 +9,70 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { PurchaseReturnSummary } from "@/types/procurement" import { PurchaseReturnSummary } from "@/types/procurement"
import { Vendor, Warehouse } from "@/types/master-data" import { Vendor, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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() { export default function PurchaseReturnsListPage() {
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null) const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
.then(([r, v, w, rc]) => { return () => clearTimeout(timeout)
setReturns(r.items) }, [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]) => {
setVendors(v.items) setVendors(v.items)
setWarehouses(w.items) setWarehouses(w.items)
setReasonCodes(rc.items) setReasonCodes(rc.items)
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, []) }, [])
function vendorCode(id: number) { function vendorCode(id: number) {
@@ -46,6 +85,8 @@ export default function PurchaseReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
} }
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -53,20 +94,42 @@ export default function PurchaseReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1> <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> <p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
</div> </div>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
</div> </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 && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && returns === null && ( {!error && returns === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -74,45 +137,107 @@ export default function PurchaseReturnsListPage() {
{!error && returns !== null && returns.length === 0 && ( {!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageX className="size-12 text-muted-foreground" /> <PackageX className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No purchase returns yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Return <Plus className="size-5" />
</Link> New Return
</Link>
)}
</div> </div>
)} )}
{!error && returns !== null && returns.length > 0 && ( {!error && returns !== null && returns.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<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) => ( {returns.map((r) => (
<TableRow key={r.returnId}> <div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell> <p className="truncate font-semibold text-foreground">{r.docNo}</p>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell> <p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
<TableCell className="px-3 py-3.5"> </div>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success"> <Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <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>
))} ))}
</TableBody>
</Table> {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>
)}
</>
)} )}
</div> </div>
) )
@@ -7,15 +7,19 @@ import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement" import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement" import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -40,6 +44,7 @@ function emptyLine(): DraftLine {
export default function NewRequisitionPage() { export default function NewRequisitionPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -47,11 +52,25 @@ export default function NewRequisitionPage() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({}) const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) 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>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
} }
@@ -67,6 +86,10 @@ export default function NewRequisitionPage() {
setSubmitError("Add at least one line.") setSubmitError("Add at least one line.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this requisition.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {} const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) { for (const line of lines) {
@@ -87,7 +110,7 @@ export default function NewRequisitionPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const requisition = await requisitionsApi.create({ lines: payloadLines }) const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`) toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) { } catch (err) {
@@ -117,6 +140,30 @@ export default function NewRequisitionPage() {
{!loading && ( {!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 flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production" import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -34,6 +36,10 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) 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() { export default function ProducedItemsPage() {
const router = useRouter() const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => { const load = useCallback(() => {
setLoadError(null) setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined, warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
.then(setData) .then((res) => {
setData(res)
// A new filter is a new list — start back at the first page.
setPage(1)
})
.catch((err) => { .catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } }) setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All" 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13). Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> {/* Two-up on phones (date range side by side, then template/warehouse), status full width —
<div className="flex flex-col gap-1.5"> five stacked labelled controls would push the results well below the fold. */}
<Label className="text-base">From</Label> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" /> <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> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">To</Label> <Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" /> <Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Template</Label> <Label className="text-sm sm:text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}> <Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" /> <SelectValue placeholder="All templates" />
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Warehouse</Label> <Label className="text-sm sm:text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" /> <SelectValue placeholder="All warehouses" />
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-base">Status</Label> <Label className="text-sm sm:text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue /> <SelectValue />
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border"> <>
<Table className="text-base"> {/* Mobile: totals first (the figure people come here for), then one card per line. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> <div className="rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <p className="text-sm font-semibold text-foreground">
<TableHead className="h-12 px-3 text-sm">Status</TableHead> Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
<TableHead className="h-12 px-3 text-sm">Template</TableHead> </p>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead> <div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead> <div>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead> <p className="text-muted-foreground">Produced</p>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead> <div>
</TableRow> <p className="text-muted-foreground">Scrapped</p>
</TableHeader> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
<TableBody> </div>
{data.items.map((line) => ( <div>
<TableRow <p className="text-muted-foreground">Good</p>
key={line.runId} <p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
className="cursor-pointer" </div>
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)} </div>
> </div>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5"> {pageItems.map((line) => (
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}> <button
{line.status === "InProgress" ? "In Progress" : line.status} key={line.runId}
</Badge> type="button"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell> className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell> >
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell> <div className="flex w-full items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell> <p className="truncate font-semibold text-foreground">{line.docNo}</p>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell> <p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell> </div>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5"> {line.status === "InProgress" ? "In Progress" : line.status}
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"} </Badge>
</TableCell> </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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
<TableFooter> {pageItems.map((line) => (
<TableRow> <TableRow
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}> key={line.runId}
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}) className="cursor-pointer"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<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 font-medium text-foreground">{line.docNo}</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" /> <Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5 text-right font-semibold"> {line.status === "InProgress" ? "In Progress" : line.status}
Cost of Production: {formatCost(data.summary.totalCost)} </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" /> <TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
</TableRow> <TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
</TableFooter> <TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
</Table> <TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
</div> <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"})
</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>
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</>
)} )}
</div> </div>
) )
@@ -140,7 +140,7 @@ export function RunActions({
return ( return (
<> <>
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}> <Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" /> <Undo2 className="size-4" />
Return leftover Return leftover
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors" import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import { import {
RunStageCardBody,
RunHeaderNodeComponent, RunHeaderNodeComponent,
RunStageNodeComponent, RunStageNodeComponent,
type RunHeaderData, type RunHeaderData,
@@ -50,7 +51,75 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/ */
const HEADER_GAP_X = 240 const HEADER_GAP_X = 240
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } { /** 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[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0 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 const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
@@ -64,42 +133,12 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
}, },
] ]
// 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) { for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({ nodes.push({
id: String(stage.runStageId), id: String(stage.runStageId),
type: "runStage", type: "runStage",
position: { x: stage.posX, y: stage.posY }, position: { x: stage.posX, y: stage.posY },
data: { data: stageData.get(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,
draggable: false, draggable: false,
}) })
} }
@@ -173,10 +212,12 @@ export default function ProductionRunDetailPage() {
}) })
}, []) }, [])
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
const { nodes, edges } = useMemo( const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }), () => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run] [run, stageData]
) )
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => { const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 } const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) { if (!run) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" /> <Skeleton className="h-10 w-full max-w-64" />
<Skeleton className="h-40 w-full rounded-2xl" /> <Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-[45vh] w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
</div> </div>
) )
} }
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend /> <StageStatusLegend />
</div> </div>
<p className="text-sm text-muted-foreground">Click a stage to open it.</p> <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>
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {/* 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">
{mounted ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -26,6 +26,9 @@ import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no pagination — 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
type StatusFilter = ProductionRunStatus | "All" type StatusFilter = ProductionRunStatus | "All"
@@ -57,6 +60,8 @@ export default function ProductionRunsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([]) const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -85,7 +90,7 @@ export default function ProductionRunsPage() {
productionRunsApi productionRunsApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
templateId: templateId ?? undefined, templateId: templateId ?? undefined,
@@ -99,10 +104,16 @@ export default function ProductionRunsPage() {
setRuns([]) setRuns([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status, templateId, warehouseId]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status, templateId, warehouseId])
useEffect(load, [load]) useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an // Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
// Inactive template that still has historical runs; the start dialog narrows to Active // Inactive template that still has historical runs; the start dialog narrows to Active
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4). // itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
@@ -172,11 +183,11 @@ export default function ProductionRunsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p> <p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle> <DialogTitle>Start a run</DialogTitle>
@@ -266,54 +277,59 @@ export default function ProductionRunsPage() {
</Dialog> </Dialog>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> {/* Search full width, then the three filters as a compact grid on phones (four stacked
<div className="relative flex-1 basis-0"> 56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
lifts the selects into the outer flex row. */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative lg:flex-1 lg:basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…" placeholder="Search doc no…"
className="h-14 w-full pl-11 text-base" className="h-12 w-full pl-11 text-base sm:h-14"
aria-label="Search runs" aria-label="Search runs"
/> />
</div> </div>
<Select<number | null> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
value={templateId} <Select<number | null>
onValueChange={(v) => { setTemplateId(v); setPage(1) }} value={templateId}
> onValueChange={(v) => { setTemplateId(v); setPage(1) }}
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> >
<SelectValue placeholder="All templates" /> <SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
</SelectTrigger> <SelectValue placeholder="All templates" />
<SelectContent> </SelectTrigger>
{templates.map((t) => ( <SelectContent>
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem> {templates.map((t) => (
))} <SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Select<number | null> </Select>
value={warehouseId} <Select<number | null>
onValueChange={(v) => { setWarehouseId(v); setPage(1) }} value={warehouseId}
> onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> >
<SelectValue placeholder="All warehouses" /> <SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
</SelectTrigger> <SelectValue placeholder="All warehouses" />
<SelectContent> </SelectTrigger>
{warehouses.map((w) => ( <SelectContent>
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem> {warehouses.map((w) => (
))} <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}> </Select>
<SelectTrigger className="h-14! w-full sm:w-48 text-base"> <Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectValue /> <SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="All" className="text-base">All statuses</SelectItem> <SelectContent>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem> <SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem> <SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem> <SelectItem value="Completed" className="text-base">Completed</SelectItem>
</SelectContent> <SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</Select> </SelectContent>
</Select>
</div>
</div> </div>
{hasFilters && ( {hasFilters && (
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
})} })}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -233,7 +233,9 @@ export function StageEditorPanel({
return ( return (
<Dialog open onOpenChange={(next) => !next && onClose()}> <Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg"> {/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Stage editor</DialogTitle> <DialogTitle>Stage editor</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -301,7 +303,7 @@ export function StageEditorPanel({
</SelectContent> </SelectContent>
</Select> </Select>
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input"> <button type="button" onClick={() => removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -404,7 +406,7 @@ export function StageEditorPanel({
/> />
)} )}
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output"> <button type="button" onClick={() => removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -462,7 +464,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm" className="h-8 flex-1 text-sm"
/> />
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field"> <button type="button" onClick={() => removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
disconnected && "opacity-40" disconnected && "opacity-40"
)} )}
> >
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" /> {/* Handles are finger-sized below md: dragging a connection starts from them. */}
<Handle type="target" position={Position.Left} className="bg-primary! size-4! md:size-2.5!" />
{selected && data.onDelete && ( {selected && data.onDelete && (
<button <button
type="button" type="button"
aria-label="Delete stage" aria-label="Delete stage"
title="Delete stage" title="Delete stage"
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90" className="nodrag absolute -top-3 -right-3 flex size-7 items-center md:-top-2.5 md:-right-2.5 md:size-5 justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
data.onDelete?.() data.onDelete?.()
@@ -60,7 +61,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>} {disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" /> <Handle type="source" position={Position.Right} className="bg-primary! size-4! md:size-2.5!" />
</div> </div>
) )
} }
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" /> <Skeleton className="h-10 w-full max-w-72" />
<Skeleton className="h-[60vh] w-full rounded-2xl" /> <Skeleton className="h-[60vh] w-full rounded-2xl" />
</div> </div>
) )
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
} }
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4"> // Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> // Only from lg up does the builder fill the viewport with the canvas taking the remainder —
<div className="flex items-center gap-3"> // on a short screen the fields and issue list would otherwise squeeze it to nothing.
<div> <div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1> <div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
{graph ? ( {graph ? (
<Badge <Badge
variant="outline" variant="outline"
@@ -683,16 +686,17 @@ function TemplateBuilderContent() {
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection {stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"} {edges.length === 1 ? "" : "s"}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> {/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{!locked && ( {!locked && (
<> <>
<Button type="button" variant="outline" onClick={addStage}> <Button type="button" variant="outline" className="col-span-2 sm:col-span-1" onClick={addStage}>
<Plus className="size-5" /> <Plus className="size-5" />
Add Stage Add Stage
</Button> </Button>
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
<Button <Button
type="button" type="button"
variant={graph.status === "Active" ? "destructive" : "success"} variant={graph.status === "Active" ? "destructive" : "success"}
className={cn(locked && "col-span-2 sm:col-span-1")}
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus} disabled={togglingStatus}
> >
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
</Button> </Button>
)} )}
{!locked && ( {!locked && (
<Button type="button" onClick={handleSave} disabled={saving || conflict}> <Button type="button" className={cn(!graph && "col-span-2 sm:col-span-1")} onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : isNew ? "Create" : "Save"} {saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button> </Button>
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-[10rem_1fr_1fr]">
<Field> <Field>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel> <FieldLabel htmlFor="tpl-code">Code</FieldLabel>
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" /> <Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
@@ -734,7 +739,7 @@ function TemplateBuilderContent() {
<FieldLabel htmlFor="tpl-name">Name</FieldLabel> <FieldLabel htmlFor="tpl-name">Name</FieldLabel>
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" /> <Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
</Field> </Field>
<Field> <Field className="sm:col-span-2 lg:col-span-1">
<FieldLabel htmlFor="tpl-description">Description</FieldLabel> <FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input <Input
id="tpl-description" id="tpl-description"
@@ -786,7 +791,7 @@ function TemplateBuilderContent() {
</div> </div>
)} )}
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> <div className="h-[65dvh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 lg:h-auto lg:min-h-0 lg:flex-1">
{mounted && ( {mounted && (
<ReactFlow <ReactFlow
nodes={displayNodes} nodes={displayNodes}
@@ -806,7 +811,8 @@ function TemplateBuilderContent() {
> >
<Background /> <Background />
<Controls showInteractive={!locked} /> <Controls showInteractive={!locked} />
<MiniMap pannable zoomable /> {/* Covers too much of a phone-sized canvas to be worth it. */}
<MiniMap pannable zoomable className="hidden! md:block!" />
</ReactFlow> </ReactFlow>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css" import "@xyflow/react/dist/style.css"
@@ -9,6 +10,7 @@ import { LayoutTemplate, Plus, Search } from "lucide-react"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production" import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { import {
@@ -29,6 +31,9 @@ import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All" type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no pagination — 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 nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent } const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -92,6 +97,8 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -119,7 +126,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi productionTemplatesApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
@@ -131,10 +138,16 @@ export default function ProductionTemplatesPage() {
setTemplates([]) setTemplates([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status])
useEffect(load, [load]) useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates]) const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
@@ -174,11 +187,11 @@ export default function ProductionTemplatesPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p> <p className="text-sm text-muted-foreground sm:text-base">Every production line, stage by stage. Tap a line to open its builder.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle> <DialogTitle>New template</DialogTitle>
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
)} )}
{templates === null ? ( {templates === null ? (
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl md:h-[70vh] md:min-h-105" />
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LayoutTemplate className="size-12 text-muted-foreground" /> <LayoutTemplate className="size-12 text-muted-foreground" />
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
narrow touch screen, so each line reads top to bottom instead. */}
<div className="flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<Link
key={t.templateId}
href={`/dashboard/production/templates/${t.templateId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
<p className="truncate font-semibold text-foreground">{t.name}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</span>
{t.activeRunCount > 0 && (
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
{t.activeRunCount} in progress
</span>
)}
</div>
</div>
{t.stageNames.length > 0 ? (
<ol className="flex flex-col gap-1.5 border-t pt-3">
{t.stageNames.map((stageName, i) => (
<li key={i} className="flex items-center gap-2.5 text-sm">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
{i + 1}
</span>
<span className="truncate text-foreground">{stageName}</span>
</li>
))}
</ol>
) : (
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
)}
</Link>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -287,7 +355,11 @@ export default function ProductionTemplatesPage() {
)} )}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -1,36 +1,69 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, Save } from "lucide-react" import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates" import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data" import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data" import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */ /** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"] const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
const NO_TAX_RATE = "__none__" const NO_TAX_RATE = "__none__"
/** Sale-channel document-type prefixes as stored on the ledger (Domain/DocumentTypes.cs). */
const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"])
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
if (sourceDocType === "SI") return `/dashboard/sales/invoices/${sourceDocId}`
return null
}
function docLabel(sourceDocType: string, sourceDocId: number): string {
const names: Record<string, string> = { GRN: "GRN", SI: "Invoice", SSL: "Slip", BND: "Bundle Sale", ADJ: "Adjustment", TRF: "Transfer", PRET: "Return" }
return `${names[sourceDocType] ?? sourceDocType} #${sourceDocId}`
}
/** A small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Package; 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 ItemDetailPage() { export default function ItemDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const itemId = Number(params.id) const itemId = Number(params.id)
const [item, setItem] = useState<Item | null>(null) const [item, setItem] = useState<Item | null>(null)
@@ -40,6 +73,10 @@ export default function ItemDetailPage() {
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Profile activity: full recent movement history for this item, across every warehouse.
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
const [stockValue, setStockValue] = useState<number | null>(null)
// Basic info form // Basic info form
const [sku, setSku] = useState("") const [sku, setSku] = useState("")
const [barcode, setBarcode] = useState("") const [barcode, setBarcode] = useState("")
@@ -77,6 +114,7 @@ export default function ItemDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
function applyItem(data: Item) { function applyItem(data: Item) {
setItem(data) setItem(data)
@@ -127,6 +165,97 @@ export default function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId]) }, [itemId])
// Recent activity + stock value: independent of the edit form above, loaded once the
// item id is known. A large page size is fine here — one item's lifetime history.
useEffect(() => {
if (!Number.isFinite(itemId)) return
stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([]))
stockApi
.onHandList({ itemId, pageSize: 200 })
.then((res) => res.items.filter((r) => r.onHand > 0))
.then((rows) =>
Promise.all(rows.map((r) => stockApi.valuation(itemId, r.warehouseId).catch(() => null)))
)
.then((valuations) => setStockValue(valuations.reduce((sum, v) => sum + (v?.totalValue ?? 0), 0)))
.catch(() => setStockValue(null))
}, [itemId])
const sortedLedger = useMemo(
() => (ledger ?? []).slice().sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
[ledger]
)
/** Cumulative on-hand qty over time, summed across every warehouse — "total stock variation". */
const stockTrend = useMemo(() => {
let running = 0
const points: { date: string; qty: number }[] = []
for (const row of sortedLedger) {
running += row.direction === "In" ? row.qtyBase : -row.qtyBase
points.push({ date: row.createdAt, qty: running })
}
// Running totals are computed over the full history above so they stay accurate —
// only the most recent 7 points are kept for display.
return points.slice(-7)
}, [sortedLedger])
/** Qty in vs qty out per month, last 5 months with activity. */
const monthlyActivity = useMemo(() => {
const byMonth = new Map<string, { in: number; out: number }>()
for (const row of sortedLedger) {
const key = row.createdAt.slice(0, 7) // YYYY-MM
const bucket = byMonth.get(key) ?? { in: 0, out: 0 }
if (row.direction === "In") bucket.in += row.qtyBase
else bucket.out += row.qtyBase
byMonth.set(key, bucket)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [sortedLedger])
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
// blessed way to do it once (rather than on every render) is a one-time init function.
// 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)
}, [])
const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000)
const salesQty30d = useMemo(
() =>
sortedLedger
.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType) && new Date(r.createdAt).getTime() >= cutoff30)
.reduce((sum, r) => sum + r.qtyBase, 0),
[sortedLedger, cutoff30]
)
const recentSales = useMemo(
() => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(),
[sortedLedger]
)
const recentGrns = useMemo(
() => sortedLedger.filter((r) => r.sourceDocType === "GRN").slice(-8).reverse(),
[sortedLedger]
)
const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0
/** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (item) applyItem(item)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
}
async function handleSave() { async function handleSave() {
if (!item || !etag) return if (!item || !etag) return
setSaveError(null) setSaveError(null)
@@ -152,6 +281,7 @@ export default function ItemDetailPage() {
) )
applyItem(result.data) applyItem(result.data)
setEtag(result.etag) setEtag(result.etag)
setEditOpen(false)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`) toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) { } catch (err) {
const code = (err as { code?: string })?.code const code = (err as { code?: string })?.code
@@ -159,6 +289,7 @@ export default function ItemDetailPage() {
setConflict(true) setConflict(true)
setSaveError(errorMessage(err)) setSaveError(errorMessage(err))
setSaving(false) setSaving(false)
setEditOpen(false)
return return
} }
const fe = fieldErrors(err) const fe = fieldErrors(err)
@@ -212,8 +343,19 @@ export default function ItemDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Link
href="/dashboard/products"
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 Items
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Package className="size-6 text-primary" />
</div>
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1> <h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -228,9 +370,26 @@ export default function ItemDetailPage() {
</div> </div>
</div> </div>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}> <div className="flex items-center gap-2">
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"} <Button size="lg" variant="outline" onClick={openEdit}>
</Button> <Pencil className="size-4" />
Edit
</Button>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
label="Stock value"
value={stockValue === null ? "…" : `LKR ${stockValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
/>
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
</div> </div>
{conflict && ( {conflict && (
@@ -249,8 +408,212 @@ export default function ItemDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)} )}
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<h2 className="text-base font-semibold text-foreground">Basic info</h2> <div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<TrendingUp className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Total stock variation</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : stockTrend.length > 1 ? (
<LineChart
labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[
{
label: `On hand (${uomName(item.baseUomId)})`,
data: stockTrend.map((p) => p.qty),
borderColor: "rgba(225,29,72,1)",
backgroundColor: "rgba(225,29,72,0.15)",
},
]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<ShoppingCart className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Monthly purchases vs sales</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : monthlyActivity.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={[
...monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill("")),
]}
datasets={[
{
label: "Purchased",
data: [
...monthlyActivity.map(([, v]) => v.in),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
{
label: "Sold",
data: [
...monthlyActivity.map(([, v]) => v.out),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p>
)}
</div>
</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">Recent sales</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentSales.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
{href ? (
<Link href={href} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
<span className="truncate font-medium text-foreground">{docLabel(row.sourceDocType, row.sourceDocId)}</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
{href ? (
<Link href={href} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
docLabel(row.sourceDocType, row.sourceDocId)
)}
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent GRNs (purchases)</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentGrns.map((row) => (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentGrns.map((row) => (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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 {item.sku}</DialogTitle>
<DialogDescription>Update this item&apos;s basic info, pricing and classification.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label> <Label className="text-base">SKU</Label>
@@ -465,16 +828,17 @@ export default function ItemDetailPage() {
</Select> </Select>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <DialogFooter>
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}> <Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Back Cancel
</Button> </Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}> <Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03). {uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react" import { Plus, Ruler, Tag } from "lucide-react"
@@ -26,6 +26,14 @@ type Tab = "brand" | "uom"
const ALL_ITEMS_PAGE_SIZE = 500 const ALL_ITEMS_PAGE_SIZE = 500
export default function BrandsPage() { export default function BrandsPage() {
return (
<Suspense>
<BrandsPageContent />
</Suspense>
)
}
function BrandsPageContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand" const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
@@ -206,14 +214,15 @@ function BrandPanel() {
key={b.brandId} key={b.brandId}
onDoubleClick={() => openEdit(b)} onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename" title="Tick to activate/deactivate — double-click to rename"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
> >
<Checkbox <Checkbox
checked={b.status === "Active"} checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)} onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId} disabled={togglingId === b.brandId}
className="shrink-0"
/> />
<span className="font-medium">{b.name}</span> <span className="truncate font-medium">{b.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -348,14 +357,15 @@ function UomPanel() {
<label <label
key={u.uomId} key={u.uomId}
title="Tick to activate/deactivate" title="Tick to activate/deactivate"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
> >
<Checkbox <Checkbox
checked={u.status === "Active"} checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)} onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId} disabled={togglingId === u.uomId}
className="shrink-0"
/> />
<span className="font-medium">{u.name}</span> <span className="truncate font-medium">{u.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -166,71 +166,139 @@ export default function CategorySubCategoriesPage() {
)} )}
{!error && subCategories !== null && subCategories.length > 0 && ( {!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subCategories.map((s) => ( {subCategories.map((s) => (
<TableRow key={s.subCategoryId}> <div key={s.subCategoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5"> <p className="truncate font-medium text-foreground">{s.name}</p>
<p className="text-sm text-muted-foreground">#{s.subCategoryId}</p>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"border-transparent", "h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive" s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)} )}
> >
{s.status} {s.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog> <div className="border-t pt-3 text-sm">
<AlertDialogTrigger <p className="text-muted-foreground">Created</p>
render={ <p className="font-medium text-foreground">{new Date(s.createdAt).toLocaleDateString()}</p>
<Button </div>
variant="ghost"
size="icon-sm" <div className="flex items-center justify-end gap-1 border-t pt-3">
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""} <Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`} <Pencil className="size-4" />
disabled={togglingId === s.subCategoryId} </Button>
/>
} <AlertDialog>
> <AlertDialogTrigger
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />} render={
</AlertDialogTrigger> <Button
<AlertDialogContent variant="ghost"
variant={s.status === "Active" ? "destructive" : "success"} size="icon-sm"
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`} className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
description={ aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
s.status === "Active" disabled={togglingId === s.subCategoryId}
? "It stays on existing items but cannot be assigned to new ones." />
: "It becomes selectable again." }
} >
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"} {s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
onConfirm={() => handleToggleStatus(s)} </AlertDialogTrigger>
/> <AlertDialogContent
</AlertDialog> variant={s.status === "Active" ? "destructive" : "success"}
</div> title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
</TableCell> description={
</TableRow> s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</div>
))} ))}
</TableBody> </div>
</Table>
{/* 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">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{s.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
</div> </div>
) )
@@ -264,101 +264,185 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && ( {!error && categories !== null && categories.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {sortedCategories!.map((c) => (
<TableHead className="h-12 px-3 text-sm"> <div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} /> <div className="flex items-start justify-between gap-2">
</TableHead> <div className="min-w-0">
<TableHead className="h-12 px-3 text-sm"> <p className="truncate font-medium text-foreground">{c.name}</p>
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /> <p className="text-sm text-muted-foreground">#{c.categoryId}</p>
</TableHead> </div>
<TableHead className="h-12 px-3 text-sm"> <Badge
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} /> variant="outline"
</TableHead> className={cn(
<TableHead className="h-12 px-3 text-sm"> "h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} /> c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
</TableHead> )}
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>} >
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> {c.status}
</TableRow> </Badge>
</TableHeader> </div>
<TableBody>
{sortedCategories!.map((c) => ( <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableRow key={c.categoryId}> <div>
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell> <p className="text-muted-foreground">Created</p>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell> <p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
<TableCell className="px-3 py-3.5"> </div>
<Badge
variant="outline"
className={cn(
"border-transparent",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && ( {config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell> <div>
)} <p className="text-muted-foreground">Subcategories</p>
<TableCell className="px-3 py-3.5"> <p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div> </div>
</TableCell> )}
</TableRow> </div>
))}
</TableBody> <div className="flex items-center justify-end gap-1 border-t pt-3">
</Table> {config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</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">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCategories!.map((c) => (
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
)}
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</> </>
@@ -1,20 +1,38 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react" import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Download,
Eye,
ListTree,
Package,
Pencil,
Plus,
Ruler,
Search,
} from "lucide-react"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands" import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config" import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data" import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ColumnsMenu } from "@/components/ui/columns-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -22,14 +40,41 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All" type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
type SortOrder = "asc" | "desc"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" },
{ key: "stockValue", label: "Stock Value" },
{ key: "lastPurchase", label: "Last Purchase" },
{ key: "sales30d", label: "Sales (30d)" },
{ key: "status", label: "Status" },
]
function formatMoney(n: number) {
return `LKR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
return (
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
{label}
{active ? (
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
export default function ItemsPage() { export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null) const router = useRouter()
const isMobile = useIsMobile()
const [items, setItems] = useState<ItemOverview[] | null>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [brands, setBrands] = useState<Brand[]>([]) const [brands, setBrands] = useState<Brand[]>([])
const [config, setConfig] = useState<ProductConfig | null>(null) const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -39,73 +84,158 @@ export default function ItemsPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All") const [categoryId, setCategoryId] = useState<number | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId, trackingMode]) // A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
setPage(1)
}
function load() { useEffect(() => {
setError(null) let cancelled = false
// Mobile "Show more" appends the next server page to what's already listed.
const append = isMobile && page > 1
itemsApi itemsApi
.list({ .overview({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId, categoryId: categoryId === "All" ? undefined : categoryId,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
}) })
.then((res) => { .then((res) => {
setItems(res.items) if (cancelled) return
setError(null)
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setPagination(res.pagination) setPagination(res.pagination)
if (!append) setSelected(new Set())
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => {
} if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setLoadingMore(false)
})
return () => {
cancelled = true
}
}, [page, pageSize, query, status, categoryId, isMobile])
useEffect(load, [page, query, status, categoryId, trackingMode]) function showMore() {
setLoadingMore(true)
setPage((p) => p + 1)
}
useEffect(() => { useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
productConfig().then(setConfig).catch(() => {}) productConfig().then(setConfig).catch(() => {})
}, []) }, [])
// No "list all subcategories" endpoint exists — they're scoped per category — so once
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
useEffect(() => {
if (categories.length === 0) return
Promise.all(
categories.map((c) =>
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
)
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
}, [categories])
function categoryName(id: number) { function categoryName(id: number) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
} }
function subCategoryName(id: number) { function brandName(id: number | null) {
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}` if (id === null) return "—"
}
function brandName(id: number) {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}` return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
} }
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
const showCol = (key: string) => visibleColumns[key] ?? true
function toggleColumn(key: string, next: boolean) {
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
}
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
const sortedItems = useMemo(() => {
if (!items) return null
return [...items].sort((a, b) => {
let cmp: number
switch (sortKey) {
case "stockValue":
cmp = a.stockValue - b.stockValue
break
case "salesQtyLast30Days":
cmp = a.salesQtyLast30Days - b.salesQtyLast30Days
break
case "lastPurchaseDate":
cmp = (a.lastPurchaseDate ? new Date(a.lastPurchaseDate).getTime() : 0) -
(b.lastPurchaseDate ? new Date(b.lastPurchaseDate).getTime() : 0)
break
case "name":
cmp = a.name.localeCompare(b.name)
break
default:
cmp = a.sku.localeCompare(b.sku)
}
return sortOrder === "asc" ? cmp : -cmp
})
}, [items, sortKey, sortOrder])
const allSelected = !!sortedItems && sortedItems.length > 0 && selected.size === sortedItems.length
function toggleAll(checked: boolean) {
setSelected(checked && sortedItems ? new Set(sortedItems.map((i) => i.itemId)) : new Set())
}
function toggleRow(itemId: number, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(itemId)
else next.delete(itemId)
return next
})
}
function handleExport() {
if (!sortedItems) return
const rows = selected.size > 0 ? sortedItems.filter((i) => selected.has(i.itemId)) : sortedItems
downloadCsv(`items-${new Date().toISOString().slice(0, 10)}`, rows, [
{ header: "SKU", value: (i) => i.sku },
{ header: "Name", value: (i) => i.name },
{ header: "Category", value: (i) => categoryName(i.categoryId) },
{ header: "Brand", value: (i) => brandName(i.brandId) },
{ header: "Status", value: (i) => i.status },
{ header: "Stock Qty", value: (i) => i.stockQty },
{ header: "Stock Value (LKR)", value: (i) => i.stockValue.toFixed(2) },
{ header: "Low Stock", value: (i) => (i.isLowStock ? "Yes" : "No") },
{ header: "Last Purchase Date", value: (i) => i.lastPurchaseDate ?? "" },
{ header: "Last Supplier", value: (i) => i.lastSupplierName ?? "" },
{ header: "Sales Qty (30d)", value: (i) => i.salesQtyLast30Days },
])
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Items</h1> <h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" /> <Ruler className="size-5" />
UOMs UOMs
@@ -149,19 +279,8 @@ export default function ItemsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select<TrackingFilter> value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All tracking modes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All tracking modes</SelectItem>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -170,13 +289,18 @@ export default function ItemsPage() {
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem> <SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-4" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
</div> </div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && items === null && ( {!error && sortedItems === null && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <Skeleton key={i} className="h-14 w-full" />
@@ -184,7 +308,7 @@ export default function ItemsPage() {
</div> </div>
)} )}
{!error && items !== null && items.length === 0 && ( {!error && sortedItems !== null && sortedItems.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Package className="size-12 text-muted-foreground" /> <Package className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p> <p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
@@ -197,68 +321,241 @@ export default function ItemsPage() {
</div> </div>
)} )}
{!error && items !== null && items.length > 0 && ( {!error && sortedItems !== null && sortedItems.length > 0 && (
<> <>
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {sortedItems.map((item) => (
<TableRow> <div
<TableHead className="h-12 px-3 text-sm">SKU</TableHead> key={item.itemId}
<TableHead className="h-12 px-3 text-sm">Name</TableHead> role="link"
<TableHead className="h-12 px-3 text-sm">Category</TableHead> tabIndex={0}
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>} onClick={(e) => {
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>} if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableHead className="h-12 px-3 text-sm">Type</TableHead> router.push(`/dashboard/products/${item.itemId}`)
<TableHead className="h-12 px-3 text-sm">Status</TableHead> }}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> onKeyDown={(e) => {
</TableRow> if (e.key === "Enter") router.push(`/dashboard/products/${item.itemId}`)
</TableHeader> }}
<TableBody> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
{items.map((item) => ( >
<TableRow key={item.itemId}> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 font-medium"> <div className="flex min-w-0 items-center gap-3">
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline"> <Checkbox
{item.sku} checked={selected.has(item.itemId)}
</Link> onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
</TableCell> aria-label={`Select ${item.sku}`}
<TableCell className="px-3 py-3.5">{item.name}</TableCell> data-no-row-nav
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell> className="mt-0.5 shrink-0"
{config?.subcategoriesEnabled && ( />
<TableCell className="px-3 py-3.5"> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"} <Package className="size-4 text-primary" />
</TableCell> </div>
)} <div className="min-w-0">
{config?.brandsEnabled && ( <p className="truncate font-medium text-foreground">{item.name}</p>
<TableCell className="px-3 py-3.5"> <p className="text-sm text-muted-foreground">{item.sku}</p>
{item.brandId !== null ? brandName(item.brandId) : "—"} </div>
</TableCell> </div>
)} {showCol("status") && (
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm", "h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive" item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)} )}
> >
{item.status} {item.status}
</Badge> </Badge>
</TableCell> )}
<TableCell className="px-3 py-3.5"> </div>
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {showCol("categoryBrand") && (
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
)}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{showCol("stockValue") && (
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</div>
)}
{showCol("sales30d") && (
<div>
<p className="text-muted-foreground">Sales (30d)</p>
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
</div>
)}
{showCol("lastPurchase") && (
<div>
<p className="text-muted-foreground">Last Purchase</p>
{item.lastPurchaseDate ? (
<>
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
</>
) : (
<p className="text-foreground">—</p>
)}
</div>
)}
</div>
<Link
href={`/dashboard/products/${item.itemId}`}
data-no-row-nav
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-3.5" />
Edit
</Link>
</div>
))}
{pagination && pagination.page < pagination.totalPages && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={showMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3">
<Checkbox checked={allSelected} onCheckedChange={(c) => toggleAll(c === true)} aria-label="Select all" />
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item) => (
<TableRow
key={item.itemId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
{item.sku}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</TableCell>
)}
{showCol("lastPurchase") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<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",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${item.sku}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -92,7 +92,7 @@ export default function ProductSettingsPage() {
{!error && !config && <Skeleton className="h-80 w-full" />} {!error && !config && <Skeleton className="h-80 w-full" />}
{!error && config && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="size-5 text-primary" /> <Package className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2> <h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react" import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, Pencil, Percent, Plus, Search } from "lucide-react"
import { taxRatesApi } from "@/lib/api/tax-rates" import { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -345,39 +346,7 @@ export default function TaxRatesPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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>
)}
</> </>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -154,11 +154,11 @@ export default function GrnDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{grn.status !== "Draft" && ( {grn.status !== "Draft" && (
<Link <Link
href="/dashboard/ledgers/general-ledger" href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
title="View this GRN's postings in the General Ledger report" title="View this GRN's postings in the General Ledger report"
> >
<BookOpen className="size-5" /> <BookOpen className="size-5" />
@@ -166,7 +166,7 @@ export default function GrnDetailPage() {
</Link> </Link>
)} )}
{grn.status === "Draft" && ( {grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}> <Button size="lg" className="w-full sm:w-auto" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" /> <PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"} {confirming ? "Confirming…" : "Confirm GRN"}
</Button> </Button>
@@ -193,7 +193,110 @@ export default function GrnDetailPage() {
</div> </div>
)} )}
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{grn.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
</div>
<HoldStatusBadge status={line.holdStatus} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM / Bin</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)} · {binFor(line.binId)}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground">
{line.unitCost.toFixed(2)}
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
<span className="block text-xs text-warning">
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
</span>
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Disc % / Net cost</p>
<p className="font-medium text-foreground">{line.discountPct.toFixed(2)}% / {line.netUnitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Received value</p>
<p className="font-medium text-foreground">{line.receivedValue.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">VAT</p>
<p className="font-medium text-foreground">{line.vatAmount.toFixed(2)} ({line.vatPct.toFixed(2)}%)</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Line total</p>
<p className="font-semibold text-foreground">{line.lineTotal.toFixed(2)}</p>
</div>
</div>
{grn.status === "Confirmed" && (
<div className="border-t pt-3">
{line.holdStatus === "OnHold" ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="success"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Release")}
>
<ShieldAlert className="size-4" />
Release
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Reject")}
>
<XCircle className="size-4" />
Reject
</Button>
</div>
) : line.holdStatus === "Rejected" ? (
<Link
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<PackageX className="size-4" />
Create Return
</Link>
) : (
<span className="text-sm text-muted-foreground">No actions</span>
)}
</div>
)}
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -339,9 +442,9 @@ export default function GrnDetailPage() {
<span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span> <span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span>
<Link <Link
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`} href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
className={cn(buttonVariants({ size: "sm" }))} className={cn(buttonVariants({ size: "sm" }), "rounded-full")}
> >
<FileText className="size-4" /> <Plus className="size-4" />
Create Purchase Invoice Create Purchase Invoice
</Link> </Link>
</div> </div>
@@ -834,7 +834,190 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />} {poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lineTab === "lines" && lines.length > 0 && ( {!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="overflow-x-auto"> <>
{/* Mobile: stacked editable cards. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
const c = computeLine(line)
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>
{line.poLineId ? (
<div className="flex h-11 items-center gap-2 text-sm">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} — {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
<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>
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Bin</Label>
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-sm">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{item?.trackingMode === "Batch" && (
<div>
<Label className="text-sm">Batch no</Label>
<Input
value={line.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
<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-sm"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<Label className="text-sm">Unit cost</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-sm"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">PO price {line.poUnitPrice.toFixed(2)}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</div>
<div>
<Label className="text-sm">VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</div>
</div>
<div>
<Label className="text-sm">Hold status</Label>
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-sm">Available</SelectItem>
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</div>
{item?.warranty === "Warranty" && (
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
<ShieldCheck className="size-4" />
Add warranty numbers
</Button>
)}
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Line total</span>
<div className="text-right">
<span className="font-semibold tabular-nums">{c.lineTotal.toFixed(2)}</span>
<p className="text-xs text-muted-foreground">net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}</p>
</div>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -1043,7 +1226,8 @@ export default function NewGrnPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)} )}
{!poLoading && lineTab === "warranty" && ( {!poLoading && lineTab === "warranty" && (
@@ -1179,11 +1363,11 @@ export default function NewGrnPage() {
)} )}
{lineTab === "lines" && ( {lineTab === "lines" && (
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"} {submitting ? "Creating…" : "Create GRN"}
</Button> </Button>
</div> </div>
@@ -2,11 +2,15 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react" import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn" import { GrnStatus, GrnSummary } from "@/types/grn"
import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
@@ -30,13 +34,19 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All" type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25 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 GrnListPage() { export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
const [grns, setGrns] = useState<GrnSummary[] | null>(null) const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -44,6 +54,22 @@ export default function GrnListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {})
}, [])
function vendorName(id: number) {
const vendor = vendors.find((v) => v.vendorId === id)
return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}`
}
function warehouseName(id: number) {
const warehouse = warehouses.find((w) => w.warehouseId === id)
return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}`
}
// Debounce the search box so typing doesn't refetch on every keystroke. // Debounce the search box so typing doesn't refetch on every keystroke.
@@ -62,7 +88,7 @@ export default function GrnListPage() {
grnsApi grnsApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
@@ -72,9 +98,15 @@ export default function GrnListPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, query, status, activeBranchId, branchingEnabled]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
@@ -87,7 +119,7 @@ export default function GrnListPage() {
Receive goods against a purchase order, or record a direct receipt. Receive goods against a purchase order, or record a direct receipt.
</p> </p>
</div> </div>
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New GRN New GRN
</Link> </Link>
@@ -140,9 +172,21 @@ export default function GrnListPage() {
)} )}
{!error && grns === null && ( {!error && grns === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -176,71 +220,167 @@ export default function GrnListPage() {
{!error && grns !== null && grns.length > 0 && ( {!error && grns !== null && grns.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {grns.map((grn) => (
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <div
<TableHead className="h-12 px-3 text-sm">PO</TableHead> key={grn.grnId}
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> role="link"
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> tabIndex={0}
<TableHead className="h-12 px-3 text-sm">Status</TableHead> onClick={(e) => {
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableHead className="h-12 px-3 text-sm">Created</TableHead> router.push(`/dashboard/receiving/grn/${grn.grnId}`)
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> }}
</TableRow> onKeyDown={(e) => {
</TableHeader> if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
<TableBody> }}
{grns.map((grn) => { className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
return ( >
<TableRow key={grn.grnId}> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5"> <div className="min-w-0">
<Link <p className="truncate font-semibold text-foreground">{grn.docNo}</p>
href={`/dashboard/receiving/grn/${grn.grnId}`} <p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
className="font-medium text-foreground hover:underline" </div>
> <GrnStatusBadge status={grn.status} />
{grn.docNo} </div>
</Link>
</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell> <div>
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell> <p className="text-muted-foreground">PO</p>
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell> <p className="font-medium text-foreground" data-no-row-nav>
<TableCell className="px-3 py-3.5"> {grn.poId ? (
<GrnStatusBadge status={grn.status} /> <Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
</TableCell> PO {String(grn.poId).padStart(2, "0")}
<TableCell className="px-3 py-3.5"> </Link>
) : (
"Direct"
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseName(grn.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Invoice</p>
<p className="font-medium text-foreground" data-no-row-nav>
{grn.status !== "Confirmed" ? ( {grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span> "—"
) : grn.purchaseInvoiceId ? ( ) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline"> <Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`} {grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link> </Link>
) : ( ) : (
<span className="text-sm text-muted-foreground">Not invoiced</span> "Not invoiced"
)} )}
</TableCell> </p>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell> </div>
<TableCell className="px-3 py-3.5"> <div>
<div className="flex items-center gap-1"> <p className="text-muted-foreground">Created</p>
<Link <p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
href={`/dashboard/receiving/grn/${grn.grnId}`} </div>
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} </div>
aria-label={`View ${grn.docNo}`} </div>
> ))}
<Eye className="size-4" />
</Link>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for {pagination && grns.length < pagination.totalItems && (
a GRN. A receipt is corrected with a reversing document, never <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
edited or erased (FR-X-05). */} {showMoreLoading ? "Loading…" : "Show more"}
</div> </Button>
</TableCell> )}
</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-40">Doc No</TableHead>
<TableHead>PO</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Status</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow> </TableRow>
) </TableHeader>
})} <TableBody>
</TableBody> {grns.map((grn) => {
</Table> return (
<TableRow
key={grn.grnId}
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/receiving/grn/${grn.grnId}`)
}}
>
<TableCell>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-semibold text-foreground hover:underline"
>
{grn.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
<span>Direct</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
<TableCell>
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell data-no-row-nav className="text-sm text-muted-foreground">
{grn.status !== "Confirmed" ? (
<span>—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<span>Not invoiced</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -2,12 +2,22 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react" import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Combobox } from "@/components/ui/combobox" import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -18,9 +28,15 @@ import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles" import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
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
type Line = CreateBundleSaleTemplateLineRequest & { key: string } type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({ const blankLine = (key: string, warehouseId: number): Line => ({
@@ -42,13 +58,26 @@ export default function BundleTemplatesPage() {
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([]) const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([]) const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
async function refreshTemplates() { async function refreshTemplates() {
const res = await bundleApi.listTemplates({ pageSize: 50 }) const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
setTemplates(res.items) setTemplates(res.items)
setPagination(res.pagination)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(() => { useEffect(() => {
@@ -70,6 +99,17 @@ export default function BundleTemplatesPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
useEffect(() => {
if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false))
}, [page, pageSize, query])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
const itemOptions = useMemo( const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })), () => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items] [items]
@@ -84,18 +124,20 @@ export default function BundleTemplatesPage() {
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 }) updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
} }
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function resetForm() { function resetForm() {
setTemplateName("") setTemplateName("")
setDescription("") setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)]) setLines([blankLine("line-1", warehouseId ?? 0)])
setError(null)
}
function openCreateForm() {
resetForm()
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) resetForm()
} }
const componentSubtotal = useMemo( const componentSubtotal = useMemo(
@@ -124,6 +166,7 @@ export default function BundleTemplatesPage() {
}) })
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`) toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm() resetForm()
setFormOpen(false)
await refreshTemplates() await refreshTemplates()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -148,170 +191,271 @@ export default function BundleTemplatesPage() {
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p> <p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div> </div>
</div> </div>
<Button size="sm" onClick={submit} disabled={saving}> <Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"} <DialogTrigger
</Button> render={
</div> <Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" />
New Bundle Template
</Button>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>New bundle template</DialogTitle>
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <FieldGroup>
<h2 className="text-sm font-semibold">Bundle details</h2> <Field>
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p> <FieldLabel htmlFor="bt-name">Template name</FieldLabel>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
<div className="space-y-1.5"> </Field>
<Label className="text-xs">Template name</Label> <Field>
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" /> <FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
</div> <Select
<div className="space-y-1.5"> value={warehouseId ? String(warehouseId) : ""}
<Label className="text-xs">Warehouse</Label> onValueChange={(v) => {
<Select const nextId = v ? Number(v) : null
value={warehouseId ? String(warehouseId) : ""} setWarehouseId(nextId)
onValueChange={(v) => { setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
const nextId = v ? Number(v) : null }}
setWarehouseId(nextId) >
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId }))) <SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
}} <SelectContent>
> {warehouses.map((w) => (
<SelectTrigger className="h-9 text-sm"> <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
<SelectValue placeholder="Select warehouse" /> {w.code} - {w.name}
</SelectTrigger> </SelectItem>
<SelectContent> ))}
{warehouses.map((w) => ( </SelectContent>
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}> </Select>
{w.code} - {w.name} </Field>
</SelectItem> <Field>
))} <FieldLabel htmlFor="bt-description">Description (optional)</FieldLabel>
</SelectContent> <Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</Select> </Field>
</div> </FieldGroup>
<div className="space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]"> {lines.map((line) => (
<div className="flex items-center justify-between border-b border-border px-4 py-3"> <FieldGroup key={line.key}>
<h2 className="text-sm font-semibold">Component items</h2> <Field>
<Button type="button" variant="outline" size="sm" onClick={addLine}> <FieldLabel>Item</FieldLabel>
<Plus className="size-4" /> Add component
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Include</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 min-w-80">
<Combobox <Combobox
items={itemOptions} items={itemOptions}
value={line.itemId || null} value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)} onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…" placeholder="Search item…"
/> />
</TableCell> </Field>
<TableCell className="px-4 py-2 min-w-40 text-sm"> <Field>
{baseUomLabel(items, uoms, line.itemId)} <FieldLabel>UOM</FieldLabel>
</TableCell> <Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
<TableCell className="px-4 py-2"> </Field>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> <Field>
</TableCell> <FieldLabel htmlFor={`bt-qty-${line.key}`}>Qty</FieldLabel>
<TableCell className="px-4 py-2"> <Input
<Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" /> id={`bt-qty-${line.key}`}
</TableCell> type="number"
<TableCell className="px-4 py-2 text-right"> min="0.0001"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
<Input
id={`bt-price-${line.key}`}
type="number"
min="0"
step="0.01"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel>Include in bundle</FieldLabel>
<button <button
type="button" type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })} onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn( className={cn(
"rounded-full px-3 py-1 text-xs font-medium", "w-fit rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground" line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)} )}
> >
{line.includeInBundle ? "Included" : "Excluded"} {line.includeInBundle ? "Included" : "Excluded"}
</button> </button>
</TableCell> </Field>
<TableCell className="px-4 py-2 text-right"> </FieldGroup>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}> ))}
<Trash2 className="size-4" />
</Button> <div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
</TableCell> <span className="text-muted-foreground">Component subtotal</span>
</TableRow> <span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
))} </div>
</TableBody>
</Table> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
</div> <Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
<div className="flex justify-end border-t border-border px-4 py-3 text-sm"> Cancel
<span className="text-muted-foreground">Component subtotal</span> </Button>
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span> <Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
</div> <Save className="size-4" />
</section> {saving ? "Saving…" : "Save bundle template"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold">Bundle templates</h3> <h3 className="text-sm font-semibold">Bundle templates</h3>
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p> <p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p>
</div> </div>
<Button variant="outline" size="sm" onClick={refreshTemplates}> <Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshTemplates}>
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm"> <Input
<TableHeader> value={searchInput}
<TableRow> onChange={(e) => setSearchInput(e.target.value)}
<TableHead className="h-12 px-4 text-sm">Template</TableHead> placeholder="Search template name or code…"
<TableHead className="h-12 px-4 text-sm">Description</TableHead> className="mt-3 h-10 w-full max-w-sm text-sm"
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead> aria-label="Search bundle templates"
<TableHead className="h-12 px-4 text-sm">Status</TableHead> />
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow> {templates.length > 0 ? (
</TableHeader> <>
<TableBody> {/* Mobile: stacked cards. */}
{templates.length > 0 ? ( <div className="mt-3 flex flex-col gap-3 md:hidden">
templates.map((t) => ( {templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40"> <div key={t.bundleSaleTemplateId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-4 py-3.5"> <div className="flex items-start justify-between gap-2">
<div className="font-medium">{t.templateName}</div> <div className="min-w-0">
<div className="text-xs text-muted-foreground">{t.templateCode}</div> <p className="truncate font-semibold text-foreground">{t.templateName}</p>
</TableCell> <p className="text-sm text-muted-foreground">{t.templateCode}</p>
<TableCell className="px-4 py-3.5">{t.description ?? "—"}</TableCell> </div>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell> <Badge
<TableCell className="px-4 py-3.5">{t.status}</TableCell> variant="outline"
<TableCell className="px-4 py-3.5 text-right"> className={cn(
<Link "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`} t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))} )}
> >
<ShoppingBag className="size-4" /> {t.status}
Sell </Badge>
</Link> </div>
</TableCell>
</TableRow> {t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
))
) : ( <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableRow> <div>
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground">Lines</p>
No bundle templates yet — save one above. <p className="font-medium text-foreground">{t.lineCount}</p>
</TableCell> </div>
</TableRow> <div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
</div>
</div>
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</div>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)} )}
</TableBody> </div>
</Table>
</div> {/* Desktop: table. */}
<div className="mt-3 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">Template</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-32 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-fit 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>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.updatedAt ? new Date(t.updatedAt).toLocaleString() : "—"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No bundle templates yet — create one above.
</div>
)}
</section> </section>
</div> </div>
) )
@@ -236,32 +236,45 @@ export default function SalesDayEndPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3> <h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
<div className="overflow-x-auto"> {report.itemBreakdown.length === 0 ? (
<Table className="text-sm"> <p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
<TableHeader> ) : (
<TableRow> <>
<TableHead className="h-10 px-3 text-sm">Item</TableHead> {/* Mobile: stacked rows. */}
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead> <div className="flex flex-col gap-1.5 md:hidden">
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead> {report.itemBreakdown.map((line) => (
</TableRow> <div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
</TableHeader> <span className="min-w-0 truncate">{line.sku} — {line.name}</span>
<TableBody> <span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{report.itemBreakdown.length === 0 ? ( {line.qty} × {line.revenue.toFixed(2)}
<TableRow> </span>
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell> </div>
</TableRow> ))}
) : ( </div>
report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}> {/* Desktop: table. */}
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell> <div className="hidden overflow-x-auto md:block">
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell> <Table className="text-sm">
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell> <TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
</TableRow> </TableRow>
)) </TableHeader>
)} <TableBody>
</TableBody> {report.itemBreakdown.map((line) => (
</Table> <TableRow key={line.itemId}>
</div> <TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div> </div>
{report.slipNumbers.length > 0 && ( {report.slipNumbers.length > 0 && (
@@ -2,11 +2,22 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react" import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox" import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -19,10 +30,16 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
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
type Line = CreateSalesSlipLineRequest & { key: string } type Line = CreateSalesSlipLineRequest & { key: string }
type FreeIssueRow = { type FreeIssueRow = {
@@ -65,14 +82,25 @@ export default function NewFreeIssuePage() {
const [cashierUserId, setCashierUserId] = useState<number | null>(null) const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")]) const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [rows, setRows] = useState<FreeIssueRow[]>([]) const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [editingRowId, setEditingRowId] = useState<number | null>(null) const [editingRowId, setEditingRowId] = useState<number | null>(null)
const [editingLines, setEditingLines] = useState<Line[]>([]) const [editingLines, setEditingLines] = useState<Line[]>([])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows() { async function refreshRows() {
const list = await salesApi.listFreeIssues({ pageSize: 50 }) const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all( const details = await Promise.all(
list.items.map(async (summary) => { list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId) const detail = await salesApi.getFreeIssue(summary.salesSlipId)
@@ -126,6 +154,10 @@ export default function NewFreeIssuePage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
}, [page, pageSize])
function updateLine(key: string, patch: Partial<Line>) { function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
} }
@@ -134,18 +166,6 @@ export default function NewFreeIssuePage() {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
} }
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function clearAllLines() {
setLines([blankLine("line-1")])
}
const itemOptions = useMemo( const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })), () => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
[items] [items]
@@ -202,6 +222,7 @@ export default function NewFreeIssuePage() {
} }
setLines([blankLine("line-1")]) setLines([blankLine("line-1")])
setFormOpen(false)
await refreshRows() await refreshRows()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -236,6 +257,7 @@ export default function NewFreeIssuePage() {
parentLineId: detailLine?.parentLineId ?? null, parentLineId: detailLine?.parentLineId ?? null,
}, },
]) ])
setFormOpen(true)
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
} }
@@ -246,12 +268,25 @@ export default function NewFreeIssuePage() {
setEditingLines([]) setEditingLines([])
} }
function openCreateForm() {
cancelEdit()
setError(null)
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) cancelEdit()
}
async function deleteRow(row: FreeIssueRow) { async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return if (row.status !== "Draft") return
try { try {
await salesApi.cancelFreeIssue(row.salesSlipId) await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo) toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) cancelEdit() if (editingRowId === row.salesSlipId) {
cancelEdit()
setFormOpen(false)
}
await refreshRows() await refreshRows()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -276,197 +311,254 @@ export default function NewFreeIssuePage() {
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p> <p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div> </div>
</div> </div>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} <Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
{editingRowId ? ( render={
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]"> <Button size="lg" onClick={openCreateForm}>
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3"> <Plus className="size-5" />
<h2 className="text-sm font-semibold text-sky-900">Inline edit free issue</h2> New Free Issue
<Button type="button" variant="outline" size="sm" onClick={cancelEdit}>
<X className="size-4" /> Cancel edit
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{activeLines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-44 text-sm">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right text-xs">editing</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : (
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Free issue lines</h2>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={clearAllLines}
disabled={lines.length === 1 && !lines[0].itemId}
>
<Eraser className="size-4" /> Clear all lines
</Button> </Button>
<Button type="button" variant="outline" size="sm" onClick={addLine}> }
<Plus className="size-4" /> Add line />
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>{editingRowId ? "Edit free issue" : "New free issue"}</DialogTitle>
<DialogDescription>Pick a customer, warehouse and cashier, then add the item lines.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="fi-customer">Customer</FieldLabel>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger id="fi-customer" className="w-full"><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-warehouse">Warehouse</FieldLabel>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger id="fi-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-cashier">Cashier</FieldLabel>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger id="fi-cashier" className="w-full"><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
{activeLines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
placeholder="Search item…"
/>
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor={`fi-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`fi-qty-${line.key}`}
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { qty: Number(e.target.value) }) : updateLine(line.key, { qty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`fi-free-${line.key}`}>Free</FieldLabel>
<Input
id={`fi-free-${line.key}`}
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { freeQty: Number(e.target.value) }) : updateLine(line.key, { freeQty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
</FieldGroup>
))}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : editingRowId ? "Update free issue" : "Create free issue"}
</Button> </Button>
</div> </div>
</div> </DialogContent>
<div className="overflow-x-auto"> </Dialog>
<Table className="text-sm"> </div>
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
)}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold">Created free issues</h3> <h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p> <p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div> </div>
<Button variant="outline" size="sm" onClick={refreshRows}> <Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm"> {rows.length > 0 ? (
<TableHeader> <>
<TableRow> {/* Mobile: stacked cards. */}
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead> <div className="mt-3 flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead> {rows.map((row) => (
<TableHead className="h-12 px-4 text-sm">Product</TableHead> <div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead> <div className="min-w-0">
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead> <p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead> <p className="text-sm text-muted-foreground">{row.slipNo}</p>
</TableRow> </div>
</TableHeader>
<TableBody> <div className="border-t pt-3 text-sm">
{rows.length > 0 ? ( <p className="font-medium text-foreground">{row.itemName}</p>
rows.map((row) => ( <p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
<TableRow key={row.salesSlipId} className="hover:bg-muted/40"> </div>
<TableCell className="px-4 py-3.5">
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div className="text-xs text-muted-foreground">{row.slipNo}</div> <div>
</TableCell> <p className="text-muted-foreground">Warehouse</p>
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell> <p className="font-medium text-foreground">{row.warehouseName}</p>
<TableCell className="px-4 py-3.5 min-w-80"> </div>
<div className="font-medium">{row.itemName}</div> <div>
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div> <p className="text-muted-foreground">Qty</p>
</TableCell> <p className="font-medium text-foreground">{row.qty}</p>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell> </div>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell> <div>
<TableCell className="px-4 py-3.5"> <p className="text-muted-foreground">Free</p>
<div className="flex justify-end gap-1.5"> <p className="font-medium text-foreground">{row.freeQty}</p>
<button </div>
type="button" </div>
onClick={() => startEdit(row)}
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800" <div className="flex items-center justify-end gap-1 border-t pt-3">
> <Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="mr-1 size-3.5" /> <Pencil className="size-4" />
Edit </Button>
</button> {row.status === "Draft" && (
{row.status === "Draft" ? ( <Button
<button variant="ghost"
type="button" size="icon-sm"
onClick={() => deleteRow(row)} className="text-destructive hover:bg-destructive/10"
className="inline-flex h-7 items-center rounded-full border border-rose-200 bg-rose-50 px-2 text-xs text-rose-700 hover:bg-rose-100 hover:text-rose-800" onClick={() => deleteRow(row)}
> aria-label={`Delete ${row.slipNo}`}
<Trash2 className="mr-1 size-3.5" /> title="Delete"
Delete >
</button> <Trash2 className="size-4" />
) : null} </Button>
</div> )}
</TableCell> </div>
</TableRow> </div>
)) ))}
) : (
<TableRow> {pagination && rows.length < pagination.totalItems && (
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground"> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
No free issues created yet. {showMoreLoading ? "Loading…" : "Show more"}
</TableCell> </Button>
</TableRow>
)} )}
</TableBody> </div>
</Table>
</div> {/* Desktop: table. */}
<div className="mt-3 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">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section> </section>
</div> </div>
) )
@@ -25,6 +25,7 @@ import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges" import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader" import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter" import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
@@ -66,6 +67,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const invoiceId = Number(resolvedParams.id) const invoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<SalesInvoice | null>(null) const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [printOpen, setPrintOpen] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([]) const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([]) const [uoms, setUoms] = useState<Uom[]>([])
@@ -202,7 +204,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
} }
async function save() { async function save() {
if (!customerId || !warehouseId || !etag) return if (!customerId || !warehouseId || !etag || !invoice) return
if (lines.some((line) => !line.itemId || !line.warehouseId)) { if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.") setError("Select item, UOM and warehouse for every line.")
return return
@@ -216,6 +218,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{ {
customerId, customerId,
warehouseId, warehouseId,
branchId: invoice.branchId,
invoiceType, invoiceType,
lines: lines.map((line) => ({ lines: lines.map((line) => ({
itemId: Number(line.itemId), itemId: Number(line.itemId),
@@ -301,17 +304,23 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p> <p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
</div> </div>
</div> </div>
<Link <button
href={`/print/sales/invoices/${invoice.salesInvoiceId}`} type="button"
target="_blank" onClick={() => setPrintOpen(true)}
rel="noopener noreferrer"
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")} className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
> >
<Printer className="size-4" /> <Printer className="size-4" />
Print Print
</Link> </button>
</div> </div>
<SalesInvoicePrintDialog
invoiceId={invoice.salesInvoiceId}
invoiceNo={invoice.invoiceNo}
open={printOpen}
onOpenChange={setPrintOpen}
/>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null} {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
<section className="bg-white print:bg-white"> <section className="bg-white print:bg-white">
@@ -1,197 +1,27 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Printer } from "lucide-react" import { useParams } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Customer } from "@/types/customers" import { buttonVariants } from "@/components/ui/button"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { SalesInvoicePrintPreview } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoice } from "@/types/sales"
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) { export default function SalesInvoicePrintPage() {
const params = useParams<{ id: string }>()
const invoiceId = Number(params.id) const invoiceId = Number(params.id)
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
setError(`Invalid invoice id '${params.id}'.`)
return
}
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
])
.then(([cust, itemRes, uomRes, whRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setInvoice(doc.data)
})
.catch((err) => setError(errorMessage(err)))
}, [params.id, invoiceId])
const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice])
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
if (error && !invoice) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!invoice) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
}
const customer = customers.find((c) => c.customerId === invoice.customerId)
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
return ( return (
<div className="flex flex-col gap-6 print:block print:gap-0"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3 print:hidden"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <Link href={`/dashboard/sales/invoices/${params.id}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to invoice">
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <ArrowLeft className="size-4" />
<ArrowLeft className="size-4" /> </Link>
</Link> <h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
<div>
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
</div>
</div>
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div> </div>
<div className="invoice-sheet rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none"> <SalesInvoicePrintPreview invoiceId={invoiceId} className="overflow-hidden rounded-2xl border shadow-sm" />
<div className="invoice-header mb-6 grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
</div>
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
</div>
</div>
</div>
<div className="mt-6 overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[34%]">Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Discount</TableHead>
<TableHead className="text-right">Tax</TableHead>
<TableHead className="text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.salesInvoiceLineId}>
<TableCell>
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{invoice.lines.some((line) => line.freeQty > 0) ? (
<div className="mt-6 rounded-2xl border border-dashed p-4 print:break-inside-avoid">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
<div key={line.salesInvoiceLineId} className="rounded-xl border bg-emerald-500/5 p-3">
<div className="font-medium text-foreground">{line.description}</div>
<div className="mt-1 text-sm text-muted-foreground">Invoice qty: {line.qty.toFixed(2)}</div>
<div className="mt-1 text-sm text-foreground">Free qty: {line.freeQty.toFixed(2)}</div>
</div>
))}
</div>
</div>
) : null}
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
</div>
<div className="rounded-2xl border p-4">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-3 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>{invoice.totals.subtotal.toFixed(2)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Discount total</span><span>{invoice.totals.discountTotal.toFixed(2)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Free qty total</span><span>{invoice.totals.freeQtyTotal.toFixed(2)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Tax total</span><span>{invoice.totals.taxTotal.toFixed(2)}</span></div>
<div className="flex justify-between border-t pt-2 text-base font-semibold"><span>Net payable</span><span>{invoice.totals.netPayable.toFixed(2)}</span></div>
<div className="flex justify-between text-muted-foreground"><span>Paid</span><span>{invoice.totals.paidAmount.toFixed(2)}</span></div>
<div className="flex justify-between text-muted-foreground"><span>Balance</span><span>{invoice.totals.balanceAmount.toFixed(2)}</span></div>
</div>
</div>
</div>
</div>
</div> </div>
) )
} }
@@ -449,12 +449,14 @@ function NewSalesInvoiceContent() {
async function submit() { async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.") if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
const payload: CreateSalesInvoiceRequest = { const payload: CreateSalesInvoiceRequest = {
customerId, customerId,
warehouseId, warehouseId,
branchId: effectiveBranchId ?? 0,
invoiceType, invoiceType,
lines: lines.map((line) => ({ lines: lines.map((line) => ({
itemId: Number(line.itemId), itemId: Number(line.itemId),
@@ -601,7 +603,182 @@ function NewSalesInvoiceContent() {
</div> </div>
</div> </div>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{displayRows.map((row, idx) => {
if (row.type === "bundle") {
const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<div key={row.groupId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-foreground">Bundle: {row.bundleName}</p>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
</li>
))}
</ul>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Bundles</Label>
<Input
type="number"
min="1"
step="1"
value={bundleQty}
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
aria-label="Bundle quantity"
/>
</div>
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Total</span>
<span className="font-semibold tabular-nums">{lkr.format(bundleTotal)}</span>
</div>
</div>
)
}
const line = row.line
const { net: lineNet } = lineTotals(line)
const scheme = freeIssueSchemeFor(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 {idx + 1}</Label>
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
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>
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div>
<Label className="text-sm">Unit price</Label>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</p>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Line total</span>
<span className="font-semibold tabular-nums">{lkr.format(lineNet)}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -771,31 +948,31 @@ function NewSalesInvoiceContent() {
</Table> </Table>
</div> </div>
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base"> <div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-border pt-4 text-sm sm:flex sm:flex-wrap sm:justify-end sm:gap-x-8 sm:gap-y-1 sm:text-base">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Gross</span> <span className="text-sm text-muted-foreground">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span> <span className="tabular-nums">{lkr.format(grossTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span> <span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">-{lkr.format(discountTotal)}</span> <span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Tax</span> <span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span> <span className="tabular-nums">{lkr.format(taxTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-muted-foreground">Payable</span> <span className="text-muted-foreground">Payable</span>
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span> <span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"} {saving ? "Creating…" : "Create Sale"}
</Button> </Button>
</div> </div>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react" import { useRouter } from "next/navigation"
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
@@ -12,13 +13,15 @@ import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { FilterBar } from "@/components/ui/filter-bar" import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Field, FieldLabel } from "@/components/ui/field" import { Field, FieldLabel } from "@/components/ui/field"
import { KpiRow } from "@/components/ui/kpi-row" import { StatCard } from "@/components/ui/stat-card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges" import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
@@ -28,7 +31,37 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25 function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
type DateRange = "All" | "Today" | "7d" | "30d"
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
{ value: "All", label: "All time", days: null },
{ value: "Today", label: "Today", days: 1 },
{ value: "7d", label: "7 days", days: 7 },
{ value: "30d", label: "30 days", days: 30 },
]
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
function toIsoDate(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
const days = dateRanges.find((r) => r.value === range)?.days
if (!days) return {}
const today = new Date()
const from = new Date(today)
from.setDate(today.getDate() - (days - 1))
return { from: toIsoDate(from), to: toIsoDate(today) }
}
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" }, { value: "All", label: "All" },
{ value: "Draft", label: "Draft" }, { value: "Draft", label: "Draft" },
@@ -37,6 +70,7 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
] ]
export default function SalesInvoicesPage() { export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -44,12 +78,18 @@ export default function SalesInvoicesPage() {
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRange>("All")
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// rowsPerPage is the user's choice; pageSize is what's fetched (grows on mobile "Show more").
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) const [payDialogOpen, setPayDialogOpen] = useState(false)
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
const [customerId, setCustomerId] = useState<number | undefined>(undefined) const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined) const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
@@ -61,7 +101,10 @@ export default function SalesInvoicesPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId]) useEffect(() => {
setPage(1)
setPageSize(rowsPerPage)
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => { useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
@@ -73,18 +116,25 @@ export default function SalesInvoicesPage() {
salesApi salesApi
.listInvoices({ .listInvoices({
page, page,
pageSize: PAGE_SIZE, pageSize,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId, customerId,
warehouseId, warehouseId,
...dateRangeBounds(dateRange),
}) })
.then((res) => { .then((res) => {
setRows(res.items) setRows(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + rowsPerPage)
}
async function handleConfirm(row: SalesInvoiceSummary) { async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId) setConfirmingId(row.salesInvoiceId)
@@ -121,11 +171,14 @@ export default function SalesInvoicesPage() {
) ?? [], ) ?? [],
[rows, query] [rows, query]
) )
const hasFilters = status !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` const printRange = dateRangeBounds(dateRange)
const printHref =
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -134,24 +187,41 @@ export default function SalesInvoicesPage() {
<h1 className="text-2xl font-bold text-foreground">Sales</h1> <h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p> <p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link <Link
href={printHref} href={printHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
> >
<Printer className="size-5" /> <Printer className="size-5" />
Print batch Print batch
</Link> </Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Sale New Sale
</Link> </Link>
</div> </div>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Overdue Invoices", value: stats.overdueSalesInvoices, icon: AlertTriangle }] : null} /> {/* Totals are over the invoices currently listed (filters + loaded page). Passed as
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats ? (
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
) : (
<Skeleton className="h-22.5 rounded-xl" />
)}
{rows !== null ? (
<>
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm"> <div className="rounded-2xl border bg-card shadow-sm">
<FilterBar <FilterBar
@@ -186,19 +256,38 @@ export default function SalesInvoicesPage() {
</> </>
} }
> >
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap items-center gap-2">
{tabs.map((t) => ( <Select<StatusFilter>
<button value={status}
key={t.value} onValueChange={(v) => setStatus(v ?? "All")}
onClick={() => setStatus(t.value)} items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
className={cn( >
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors", <SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground" <ListFilter className="size-4 text-muted-foreground" />
)} <SelectValue />
> </SelectTrigger>
{t.label} <SelectContent>
</button> {tabs.map((t) => (
))} <SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<DateRange>
value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")}
items={dateRanges.map((r) => ({ label: r.label, value: r.value }))}
>
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{dateRanges.map((r) => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</FilterBar> </FilterBar>
@@ -221,7 +310,99 @@ export default function SalesInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && ( {!error && rows !== null && visibleRows.length > 0 && (
<> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
{visibleRows.map((row) => (
<div
key={row.salesInvoiceId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
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">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<SalesPaymentStatusBadge 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">Date / Due</p>
<p className="font-medium text-foreground">
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Gross / Discount</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
<Printer className="size-4" />
Print
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
</div>
))}
{pagination && rows !== null && rows.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-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -239,7 +420,14 @@ export default function SalesInvoicesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleRows.map((row) => ( {visibleRows.map((row) => (
<TableRow key={row.salesInvoiceId} className="hover:bg-muted/40"> <TableRow
key={row.salesInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium"> <TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline"> <Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo} {row.invoiceNo}
@@ -260,8 +448,18 @@ export default function SalesInvoicesPage() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5 text-right"> <TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setPrintInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
>
<Printer className="size-4" />
</Button>
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
type="button" type="button"
@@ -287,14 +485,6 @@ export default function SalesInvoicesPage() {
Pay Pay
</Button> </Button>
)} )}
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.invoiceNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -303,26 +493,14 @@ export default function SalesInvoicesPage() {
</Table> </Table>
</div> </div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Gross total</div>
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Discount total</div>
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Net total</div>
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && ( {pagination && (
<div className="border-t px-4 py-4"> <div className="hidden border-t px-4 py-4 md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} /> <PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={setRowsPerPage}
/>
</div> </div>
)} )}
</> </>
@@ -330,6 +508,12 @@ export default function SalesInvoicesPage() {
</div> </div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} /> <SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<SalesInvoicePrintDialog
invoiceId={printInvoice?.salesInvoiceId ?? null}
invoiceNo={printInvoice?.invoiceNo}
open={printInvoice !== null}
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
/>
</div> </div>
) )
} }
@@ -1,34 +1,10 @@
"use client" "use client"
import { Suspense, useEffect, useState } from "react" import { Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { salesReturnsApi } from "@/lib/api/sales-returns" import { SalesReturnForm } from "@/components/sales/SalesReturnForm"
import { salesApi } from "@/lib/api/sales"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales"
import { cn } from "@/lib/utils"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
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" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
}
function NewSalesReturnContent() { function NewSalesReturnContent() {
const router = useRouter() const router = useRouter()
@@ -36,143 +12,6 @@ function NewSalesReturnContent() {
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
const presetLineId = Number(searchParams.get("lineId")) || null const presetLineId = Number(searchParams.get("lineId")) || null
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [remainingLoading, setRemainingLoading] = useState(false)
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lineState, setLineState] = useState<Record<number, LineState>>({})
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
.then(([invoiceList, it, rc]) => {
// Only Posted invoices have stock movements to return against.
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
useEffect(() => {
if (!selectedInvoice) {
setLineState({})
setRemainingByLine({})
return
}
const next: Record<number, LineState> = {}
for (const line of selectedInvoice.lines) {
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
}
setLineState(next)
setRemainingLoading(true)
salesReturnsApi
.getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => {
const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
setRemainingByLine(map)
if (presetLineId) {
const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
}
})
.catch((err) => setLoadError(errorMessage(err)))
.finally(() => setRemainingLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvoice?.salesInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function remainingFor(lineId: number, fallback: number) {
return remainingByLine[lineId] ?? fallback
}
function toggleLine(line: SalesInvoiceLine) {
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
setLineState((prev) => ({
...prev,
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
}))
}
function setQty(lineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (!selectedInvoice) {
setHeaderError("Select an invoice to return against.")
return
}
if (!reasonCodeId) {
setHeaderError("Select a reason code.")
return
}
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
if (selectedLines.length === 0) {
setSubmitError("Select at least one line to return.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) {
const errors = validateSalesReturnLine({
salesInvoiceLineId: line.salesInvoiceLineId,
qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
})
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
}
setLineErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty),
}))
setSubmitting(true)
try {
const salesReturn = await salesReturnsApi.create({
customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId,
reasonCodeId,
lines: payloadLines,
})
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
router.push("/dashboard/sales/sales-returns")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post sales return", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !invoices
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -182,118 +21,12 @@ function NewSalesReturnContent() {
</div> </div>
</div> </div>
{loadError && ( <SalesReturnForm
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div> presetInvoiceId={presetInvoiceId}
)} presetLineId={presetLineId}
onCancel={() => router.push("/dashboard/sales/sales-returns")}
{loading && !loadError && <Skeleton className="h-24 w-full" />} onSuccess={() => router.push("/dashboard/sales/sales-returns")}
/>
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Sales Invoice</Label>
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a posted invoice" />
</SelectTrigger>
<SelectContent>
{(invoices ?? []).map((inv) => (
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
{inv.invoiceNo} — {inv.customerSnapshotName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{reasonCodes.map((rc) => (
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
{rc.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
{selectedInvoice && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
<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">Invoiced qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Remaining qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedInvoice.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
const fullyReturned = !remainingLoading && remaining <= 0
return (
<TableRow key={line.salesInvoiceLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
{remainingLoading ? "…" : fullyReturned ? <span className="text-muted-foreground">Fully returned</span> : remaining}
</TableCell>
<TableCell className="px-3 py-3.5">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected || fullyReturned}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/sales-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
</>
)}
</div> </div>
) )
} }
@@ -9,32 +9,71 @@ import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { SalesReturnSummary } from "@/types/sales" import { SalesReturnSummary } from "@/types/sales"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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 SalesReturnsListPage() { export default function SalesReturnsListPage() {
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null) const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
.then(([r, c, w, rc]) => { return () => clearTimeout(timeout)
setReturns(r.items) }, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
salesReturnsApi
.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([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([c, w, rc]) => {
setCustomers(c.items) setCustomers(c.items)
setWarehouses(w.items) setWarehouses(w.items)
setReasonCodes(rc.items) setReasonCodes(rc.items)
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, []) }, [])
function customerLabel(id: number) { function customerLabel(id: number) {
@@ -48,6 +87,8 @@ export default function SalesReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
} }
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -55,20 +96,42 @@ export default function SalesReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1> <h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p> <p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
</div> </div>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
</div> </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., customer…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search sales returns"
/>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && returns === null && ( {!error && returns === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -76,47 +139,113 @@ export default function SalesReturnsListPage() {
{!error && returns !== null && returns.length === 0 && ( {!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Undo2 className="size-12 text-muted-foreground" /> <Undo2 className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No sales returns yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No sales returns match your search." : "No sales returns yet."}</p>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Return <Plus className="size-5" />
</Link> New Return
</Link>
)}
</div> </div>
)} )}
{!error && returns !== null && returns.length > 0 && ( {!error && returns !== null && returns.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</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-right text-sm">Return Qty</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) => ( {returns.map((r) => (
<TableRow key={r.returnId}> <div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell> <p className="truncate font-semibold text-foreground">{r.docNo}</p>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell> <p className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</p>
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell> </div>
<TableCell className="px-3 py-3.5"> <Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <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">Return qty</p>
<p className="font-medium text-foreground">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && 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-40">Doc No</TableHead>
<TableHead className="w-44">Customer</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-36">Reason</TableHead>
<TableHead className="w-28 text-right">Return Qty</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-32">GL Journal</TableHead>
<TableHead className="w-40">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">{customerLabel(r.customerId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</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>
)}
</>
)} )}
</div> </div>
) )
@@ -86,7 +86,7 @@ export default function BranchConfigurationPage() {
{!error && !config && <Skeleton className="h-40 w-full" />} {!error && !config && <Skeleton className="h-40 w-full" />}
{!error && config && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="size-5 text-primary" /> <MapPin className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Branches</h2> <h2 className="text-lg font-semibold text-foreground">Branches</h2>
@@ -160,7 +160,7 @@ export default function CompanyProfilePage() {
{!error && !profile && <Skeleton className="h-96 w-full" />} {!error && !profile && <Skeleton className="h-96 w-full" />}
{!error && profile && ( {!error && profile && (
<div className="flex flex-col gap-6 rounded-xl border p-6"> <div className="flex flex-col gap-6 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" /> <Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Identity & contact</h2> <h2 className="text-lg font-semibold text-foreground">Identity & contact</h2>
@@ -272,14 +272,14 @@ export default function CompanyProfilePage() {
)} )}
</div> </div>
<div className="flex items-center justify-between border-t pt-4"> <div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
{profile.updatedAt && ( {profile.updatedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last changed {new Date(profile.updatedAt).toLocaleString()} Last changed {new Date(profile.updatedAt).toLocaleString()}
{profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}. {profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.
</p> </p>
)} )}
<Button type="button" onClick={save} disabled={saving} className="ml-auto"> <Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </div>
@@ -106,7 +106,7 @@ export default function InvoiceTemplatePage() {
{!error && config && ( {!error && config && (
<> <>
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutTemplate className="size-5 text-primary" /> <LayoutTemplate className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Layout</h2> <h2 className="text-lg font-semibold text-foreground">Layout</h2>
@@ -172,20 +172,20 @@ export default function InvoiceTemplatePage() {
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex items-center justify-between pt-2"> <div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
{config.updatedAt && ( {config.updatedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()} Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}. {config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p> </p>
)} )}
<Button type="button" onClick={save} disabled={saving} className="ml-auto"> <Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-6"> <div className="flex flex-col gap-3 rounded-xl border border-dashed p-4 sm:p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span> <span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span>
{(() => { {(() => {
const layout = getInvoiceLayoutClasses(templateStyle) const layout = getInvoiceLayoutClasses(templateStyle)
@@ -199,7 +199,7 @@ export default function InvoiceTemplatePage() {
const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} /> const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} />
return ( return (
<div className={`rounded-2xl border bg-white ${layout.sheet}`}> <div className={`flex flex-col rounded-2xl border bg-white ${layout.sheet}`}>
{layout.arrangement === "compact" ? ( {layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-start justify-between ${layout.header}`}> <div className={`flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock} {titleBlock}
@@ -225,7 +225,7 @@ export default function InvoiceTemplatePage() {
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div> <div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div>
</div> </div>
) : ( ) : (
<div className={`grid grid-cols-2 ${layout.infoGrid}`}> <div className={`grid grid-cols-1 sm:grid-cols-2 ${layout.infoGrid}`}>
<div className={layout.card}> <div className={layout.card}>
<div className={layout.cardLabel}>Customer</div> <div className={layout.cardLabel}>Customer</div>
<div className={layout.cardValue}>Sample Customer Ltd</div> <div className={layout.cardValue}>Sample Customer Ltd</div>
@@ -243,22 +243,24 @@ export default function InvoiceTemplatePage() {
)} )}
<div className={layout.tableWrap}> <div className={layout.tableWrap}>
<table className="w-full text-sm"> <div className="overflow-x-auto">
<thead className={layout.tableHeaderRow}> <table className="w-full text-sm">
<tr> <thead className={layout.tableHeaderRow}>
<th className="p-2 text-left font-semibold">Item</th> <tr>
<th className="p-2 text-right font-semibold">Qty</th> <th className="p-2 text-left font-semibold">Item</th>
<th className="p-2 text-right font-semibold">Line total</th> <th className="p-2 text-right font-semibold">Qty</th>
</tr> <th className="p-2 text-right font-semibold">Line total</th>
</thead> </tr>
<tbody> </thead>
<tr className="border-t"> <tbody>
<td className="p-2">Sample product</td> <tr className="border-t">
<td className="p-2 text-right">2.00</td> <td className="p-2">Sample product</td>
<td className="p-2 text-right">12,450.00</td> <td className="p-2 text-right">2.00</td>
</tr> <td className="p-2 text-right">12,450.00</td>
</tbody> </tr>
</table> </tbody>
</table>
</div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
@@ -164,28 +164,26 @@ export default function RoleDetailPage() {
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div>
<div> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3"> <h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1> <Badge
<Badge variant="outline"
variant="outline" className={cn(
className={cn( "h-6 w-fit justify-center border-transparent px-2.5 text-sm",
"h-6 w-fit justify-center border-transparent px-2.5 text-sm", role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground" )}
)} >
> {role.status}
{role.status} </Badge>
</Badge>
</div>
<p className="text-base text-muted-foreground">{role.name}</p>
</div> </div>
<p className="text-base text-muted-foreground">{role.name}</p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
className={role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10"} className={cn("w-full sm:w-auto", role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus || role.isSystemRole} disabled={togglingStatus || role.isSystemRole}
title={role.isSystemRole ? "System roles can't be deactivated" : undefined} title={role.isSystemRole ? "System roles can't be deactivated" : undefined}
@@ -280,12 +278,12 @@ export default function RoleDetailPage() {
{!allActionsSet && ( {!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p> <p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p>
)} )}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back Back
</Button> </Button>
<Button onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}> <Button className="w-full sm:w-auto" onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}>
<Save className="size-4" /> <Save className="size-4" />
{savingPermissions ? "Saving…" : "Save permissions"} {savingPermissions ? "Saving…" : "Save permissions"}
</Button> </Button>
@@ -179,16 +179,16 @@ export default function NewRolePage() {
{!allActionsSet && ( {!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p> <p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p>
)} )}
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back Back
</Button> </Button>
<div className="flex gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}> <Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button onClick={handleCreate} disabled={submitting || !allActionsSet}> <Button className="w-full sm:w-auto" onClick={handleCreate} disabled={submitting || !allActionsSet}>
<Save className="size-4" /> <Save className="size-4" />
{submitting ? "Creating…" : "Create role"} {submitting ? "Creating…" : "Create role"}
</Button> </Button>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -29,27 +30,39 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
// 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 RolesPage() { export default function RolesPage() {
const router = useRouter()
const [roles, setRoles] = useState<Role[] | null>(null) const [roles, setRoles] = useState<Role[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
rolesApi rolesApi
.list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { .then((res) => {
setRoles(res.items) setRoles(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, debouncedSearch]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -168,150 +181,212 @@ export default function RolesPage() {
)} )}
{!error && roles !== null && roles.length > 0 && ( {!error && roles !== null && roles.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {roles.map((r) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 w-56 px-3 text-sm">Role</TableHead> key={r.roleId}
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead> role="link"
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead> tabIndex={0}
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead> onClick={(e) => {
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableRow> router.push(`/dashboard/settings/roles/${r.roleId}`)
</TableHeader> }}
<TableBody> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/roles/${r.roleId}`) }}
{roles.map((r) => ( className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20"> >
<TableCell className="px-3 py-4"> <div className="flex items-start justify-between gap-2">
<Link <div className="flex min-w-0 items-start gap-3">
href={`/dashboard/settings/roles/${r.roleId}`} <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50" <ShieldCheck className="size-4.5" />
> </div>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground"> <div className="flex flex-col gap-1 pt-0.5">
<ShieldCheck className="size-4.5" /> <span className="truncate font-semibold leading-tight text-foreground">{r.name}</span>
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{r.code}</span> <span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</TableCell> </div>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{r.status}
</Badge>
</div>
<TableCell className="px-3 py-4"> <div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<Badge <div className="flex items-center gap-2">
variant="outline" <span className="text-muted-foreground">
className={cn( {new Date(r.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
"h-5 w-fit justify-center bg-white px-2 text-xs", </span>
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" {r.isSystemRole && (
)} <Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
> System
{r.status}
</Badge> </Badge>
</TableCell> )}
</div>
<TableCell className="px-3 py-4 text-sm text-muted-foreground"> <div className="flex items-center gap-1" data-no-row-nav>
{new Date(r.createdAt).toLocaleDateString(undefined, { <Link
year: "numeric", href={`/dashboard/settings/roles/${r.roleId}`}
month: "short", className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
day: "numeric", aria-label={`Edit ${r.code}`}
})} >
</TableCell> <Pencil className="size-4" />
</Link>
<TableCell className="px-3 py-4"> <AlertDialog>
<div className="flex items-center justify-center gap-1"> <AlertDialogTrigger
<Link render={
href={`/dashboard/settings/roles/${r.roleId}`} <Button
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} variant="ghost"
aria-label={`View ${r.code}`} size="icon-sm"
title={`View ${r.code}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
> aria-label={`Delete ${r.code}`}
<Eye className="size-4" /> title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
</Link> disabled={r.isSystemRole || actionPendingId === r.roleId}
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/> />
</AlertDialog> }
</div> >
</TableCell> <Trash2 className="size-4" />
</TableRow> </AlertDialogTrigger>
))} <AlertDialogContent
</TableBody> variant="destructive"
</Table> title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</div>
</div>
))}
{pagination && roles.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</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 className="hover:bg-transparent">
<TableHead className="h-11 w-56 px-3 text-sm">Role</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow
key={r.roleId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/roles/${r.roleId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<p className="text-sm text-muted-foreground"> <PaginationFooter
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– pagination={pagination}
<span className="font-medium text-foreground"> onPageChange={setPage}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} />
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
</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 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>
)} )}
</div> </div>
@@ -95,9 +95,9 @@ export default function UserDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1> <h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
<Badge <Badge
variant="outline" variant="outline"
@@ -197,8 +197,8 @@ export default function UserDetailPage() {
{!activityError && activity !== null && activity.length > 0 && ( {!activityError && activity !== null && activity.length > 0 && (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{activity.map((a) => ( {activity.map((a) => (
<li key={a.auditId} className="flex items-center justify-between gap-3 py-3"> <li key={a.auditId} className="flex flex-wrap items-center justify-between gap-2 py-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -251,8 +251,8 @@ export default function UserDetailPage() {
{!devicesError && devices !== null && devices.length > 0 && ( {!devicesError && devices !== null && devices.length > 0 && (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{devices.map((d) => ( {devices.map((d) => (
<li key={d.loginActivityId} className="flex items-center justify-between gap-3 py-3"> <li key={d.loginActivityId} className="flex flex-wrap items-center justify-between gap-2 py-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Laptop className="size-4 shrink-0 text-muted-foreground" /> <Laptop className="size-4 shrink-0 text-muted-foreground" />
<span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span> <span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span>
{d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>} {d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>}
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" import { useRouter } from "next/navigation"
import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -30,8 +31,12 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// 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 UsersPage() { export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<ManagedUser[] | null>(null) const [users, setUsers] = useState<ManagedUser[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -41,6 +46,7 @@ export default function UsersPage() {
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
usersApi usersApi
@@ -50,6 +56,12 @@ export default function UsersPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) useEffect(load, [page, pageSize, debouncedSearch])
@@ -188,135 +200,181 @@ export default function UsersPage() {
)} )}
{!error && users !== null && users.length > 0 && ( {!error && users !== null && users.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {users.map((u) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 w-48 px-3 text-sm">Username</TableHead> key={u.userId}
<TableHead className="h-11 w-48 px-3 text-sm">Display name</TableHead> role="link"
<TableHead className="h-11 w-40 px-3 text-sm">Role</TableHead> tabIndex={0}
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead> onClick={(e) => {
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableRow> router.push(`/dashboard/settings/users/${u.userId}`)
</TableHeader> }}
<TableBody> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/users/${u.userId}`) }}
{users.map((u) => ( className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<TableRow key={u.userId} className="group align-top hover:bg-muted/20"> >
<TableCell className="px-3 py-4"> <div className="flex items-start justify-between gap-2">
<Link <div className="min-w-0">
href={`/dashboard/settings/users/${u.userId}`} <p className="truncate font-semibold text-foreground">{u.username}</p>
className="font-semibold leading-tight text-foreground group-hover:underline" <p className="text-sm text-muted-foreground">{u.displayName}</p>
> </div>
{u.username} <Badge
</Link> variant="outline"
</TableCell> className={cn(
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.displayName}</TableCell> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.roleName ?? "—"}</TableCell> u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<TableCell className="px-3 py-4"> )}
<Badge >
variant="outline" {u.status}
className={cn( </Badge>
"h-5 w-fit justify-center bg-white px-2 text-xs", </div>
u.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)} <div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
> <span className="text-muted-foreground">{u.roleName ?? "—"}</span>
{u.status} <div className="flex items-center gap-1" data-no-row-nav>
</Badge> <Link
</TableCell> href={`/dashboard/settings/users/${u.userId}`}
<TableCell className="px-3 py-4"> className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<div className="flex items-center justify-center gap-1"> aria-label={`View ${u.username}`}
<Link title={`View ${u.username}`}
href={`/dashboard/settings/users/${u.userId}`} >
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <Eye className="size-4" />
aria-label={`View ${u.username}`} </Link>
title={`View ${u.username}`} <Link
> href={`/dashboard/settings/users/${u.userId}`}
<Eye className="size-4" /> className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
</Link> aria-label={`Edit ${u.username}`}
<Link title={`Edit ${u.username}`}
href={`/dashboard/settings/users/${u.userId}`} >
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <Pencil className="size-4" />
aria-label={`Edit ${u.username}`} </Link>
title={`Edit ${u.username}`} <Button
> type="button"
<Pencil className="size-4" /> variant="ghost"
</Link> size="icon-sm"
<Button className={cn(
type="button" u.status === "Active"
variant="ghost" ? "text-destructive hover:bg-destructive/10"
size="icon-sm" : "text-success hover:bg-success/10"
className={cn( )}
u.status === "Active" aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
? "text-destructive hover:bg-destructive/10" title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
: "text-success hover:bg-success/10" disabled={actionPendingId === u.userId}
)} onClick={() => handleToggleStatus(u)}
aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`} >
title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`} {u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />}
disabled={actionPendingId === u.userId} </Button>
onClick={() => handleToggleStatus(u)} </div>
> </div>
{u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />} </div>
</Button> ))}
</div>
</TableCell> {pagination && users.length < pagination.totalItems && (
</TableRow> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
))} {showMoreLoading ? "Loading…" : "Show more"}
</TableBody> </Button>
</Table> )}
</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 w-48 px-3 text-sm">Username</TableHead>
<TableHead className="h-11 w-48 px-3 text-sm">Display name</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Role</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>
{users.map((u) => (
<TableRow
key={u.userId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/users/${u.userId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className="font-semibold leading-tight text-foreground group-hover:underline"
>
{u.username}
</Link>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.displayName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.roleName ?? "—"}</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
u.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{u.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/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${u.username}`}
title={`Edit ${u.username}`}
>
<Pencil className="size-4" />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
u.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
disabled={actionPendingId === u.userId}
onClick={() => handleToggleStatus(u)}
>
{u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
</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>
)} )}
</div> </div>
@@ -244,7 +244,7 @@ export default function NewAdjustmentPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" /> <Plus className="size-5" />
@@ -252,18 +252,13 @@ export default function NewAdjustmentPage() {
</Button> </Button>
</div> </div>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {lines.map((line) => (
<TableHead className="h-12 w-72 px-3 text-sm">Item</TableHead> <div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 w-40 px-3 text-sm">Qty change</TableHead> <div className="flex items-start gap-2">
<TableHead className="h-12 w-10 px-3" /> <div className="flex-1">
</TableRow> <Label className="text-sm">Item</Label>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" /> <SelectValue placeholder="Select item" />
@@ -276,39 +271,90 @@ export default function NewAdjustmentPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Input <Trash2 className="size-5" />
type="number" </Button>
step="any" </div>
placeholder="e.g. -15 or 50"
value={line.qtyDelta} <div>
aria-invalid={!!lineErrors[line.key]} <Label className="text-sm">Qty change</Label>
onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })} <Input
className="h-11 text-base" type="number"
/> step="any"
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} /> inputMode="decimal"
</TableCell> placeholder="e.g. -15 or 50"
<TableCell className="px-3 py-3 align-top"> value={line.qtyDelta}
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> aria-invalid={!!lineErrors[line.key]}
<Trash2 className="size-5" /> onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })}
</Button> className="h-11 text-base"
</TableCell> />
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : 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-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Qty change</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<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>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
step="any"
placeholder="e.g. -15 or 50"
value={line.qtyDelta}
aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : 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>
</div> </div>
{submitError && ( {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Adjustment"} {submitting ? "Posting…" : "Post Adjustment"}
</Button> </Button>
</div> </div>
@@ -2,45 +2,80 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Plus, SlidersHorizontal } from "lucide-react" import { Plus, Search, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockAdjustmentSummary } from "@/types/stock" import { StockAdjustmentSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" 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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges" import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
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 AdjustmentsListPage() { export default function AdjustmentsListPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null) const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map()) const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockAdjustmentsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
reasonCodesApi.list("Adjustment"),
]) useEffect(() => setPage(1), [query])
.then(([a, wh, rc]) => {
setAdjustments(a.items) useEffect(() => {
setError(null)
stockAdjustmentsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setAdjustments(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
Promise.all([warehousesApi.list(), reasonCodesApi.list("Adjustment")])
.then(([wh, rc]) => {
setWarehouses(wh.items) setWarehouses(wh.items)
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description]))) setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, [activeBranchId, branchingEnabled]) }, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -51,20 +86,45 @@ export default function AdjustmentsListPage() {
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p> <p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Adjustment New Adjustment
</Link> </Link>
</div> </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 doc no…"
className="h-12 w-full pl-11 text-base"
aria-label="Search stock adjustments"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && adjustments === null && ( {!error && adjustments === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -72,41 +132,98 @@ export default function AdjustmentsListPage() {
{!error && adjustments !== null && adjustments.length === 0 && ( {!error && adjustments !== null && adjustments.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SlidersHorizontal className="size-12 text-muted-foreground" /> <SlidersHorizontal className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No adjustments yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No adjustments match your search." : "No adjustments yet."}</p>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Adjustment <Plus className="size-5" />
</Link> New Adjustment
</Link>
)}
</div> </div>
)} )}
{!error && adjustments !== null && adjustments.length > 0 && ( {!error && adjustments !== null && adjustments.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</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>
{adjustments.map((a) => ( {adjustments.map((a) => (
<TableRow key={a.adjustmentId}> <div key={a.adjustmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5 font-medium">{a.docNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{whCode(a.warehouseId)}</TableCell> <p className="truncate font-semibold text-foreground">{a.docNo}</p>
<TableCell className="px-3 py-3.5">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<AdjustmentStatusBadge status={a.status} /> <AdjustmentStatusBadge status={a.status} />
</TableCell> </div>
<TableCell className="px-3 py-3.5">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{whCode(a.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{a.glJournalNo ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(a.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && adjustments.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-40">Doc No</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-36">Reason</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-32">GL Journal</TableHead>
<TableHead className="w-40">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adjustments.map((a) => (
<TableRow key={a.adjustmentId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{a.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{whCode(a.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
<TableCell>
<AdjustmentStatusBadge status={a.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(a.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>
)}
</>
)} )}
</div> </div>
) )
@@ -139,14 +139,15 @@ export default function CountDetailPage() {
</div> </div>
{count.status !== "Posted" && ( {count.status !== "Posted" && (
<div className="flex flex-col items-end gap-1.5"> <div className="flex flex-col items-stretch gap-1.5 sm:items-end">
<div className="flex gap-3"> <div className="flex flex-col gap-3 sm:flex-row">
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}> <Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save counts"} {saving ? "Saving…" : "Save counts"}
</Button> </Button>
<Button <Button
size="lg" size="lg"
className="w-full sm:w-auto"
onClick={handlePost} onClick={handlePost}
disabled={posting || count.status !== "Counted"} disabled={posting || count.status !== "Counted"}
title={count.status !== "Counted" ? "Save counts first" : undefined} title={count.status !== "Counted" ? "Save counts first" : undefined}
@@ -191,25 +192,22 @@ export default function CountDetailPage() {
</div> </div>
)} )}
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {count.lines.map((line) => (
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <div key={line.countLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 px-3 text-sm">Lot</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-12 px-3 text-sm">System qty</TableHead> <p className="font-medium text-foreground">Item #{line.itemId}</p>
<TableHead className="h-12 px-3 text-sm">Counted qty</TableHead> <p className="text-sm text-muted-foreground">{line.batchNo ?? line.serialNo ?? "—"}</p>
<TableHead className="h-12 px-3 text-sm">Variance</TableHead> </div>
</TableRow>
</TableHeader> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableBody> <div>
{count.lines.map((line) => ( <p className="text-muted-foreground">System qty</p>
<TableRow key={line.countLineId}> <p className="font-medium text-foreground">{line.systemQty}</p>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell> </div>
<TableCell className="px-3 py-3.5 text-sm text-muted-foreground"> <div>
{line.batchNo ?? line.serialNo ?? "—"} <p className="text-muted-foreground">Counted qty</p>
</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status !== "Posted" ? ( {count.status !== "Posted" ? (
<Input <Input
ref={(el) => { ref={(el) => {
@@ -217,21 +215,70 @@ export default function CountDetailPage() {
}} }}
type="number" type="number"
step="any" step="any"
inputMode="decimal"
value={drafts[line.countLineId] ?? ""} value={drafts[line.countLineId] ?? ""}
onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))} onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))}
className="h-11 w-32 text-base" className="h-11 text-base"
/> />
) : ( ) : (
(line.countedQty ?? "—") <p className="font-medium text-foreground">{line.countedQty ?? "—"}</p>
)} )}
</TableCell> </div>
<TableCell className={cn("px-3 py-3.5 font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}> <div>
{line.variance ?? "—"} <p className="text-muted-foreground">Variance</p>
</TableCell> <p className={cn("font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}>
{line.variance ?? "—"}
</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Lot</TableHead>
<TableHead className="h-12 px-3 text-sm">System qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Counted qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Variance</TableHead>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {count.lines.map((line) => (
<TableRow key={line.countLineId}>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5 text-sm text-muted-foreground">
{line.batchNo ?? line.serialNo ?? "—"}
</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status !== "Posted" ? (
<Input
ref={(el) => {
qtyInputRefs.current[line.countLineId] = el
}}
type="number"
step="any"
value={drafts[line.countLineId] ?? ""}
onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))}
className="h-11 w-32 text-base"
/>
) : (
(line.countedQty ?? "—")
)}
</TableCell>
<TableCell className={cn("px-3 py-3.5 font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}>
{line.variance ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
) )
} }
@@ -194,11 +194,11 @@ export default function NewCountPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Count"} {submitting ? "Creating…" : "Create Count"}
</Button> </Button>
</div> </div>
@@ -2,44 +2,78 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ClipboardList, Plus } from "lucide-react" import { useRouter } from "next/navigation"
import { ClipboardList, Eye, Plus, Search } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts" import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockCountSummary } from "@/types/stock" import { StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges" import { CountStatusBadge } from "@/components/stock/status-badges"
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 CountsListPage() { export default function CountsListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null) const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockCountsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
])
.then(([c, wh]) => { useEffect(() => setPage(1), [query])
setCounts(c.items)
setWarehouses(wh.items) useEffect(() => {
setError(null)
stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setCounts(res.items)
setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [activeBranchId, branchingEnabled]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -50,7 +84,7 @@ export default function CountsListPage() {
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p> <p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Count New Count
</Link> </Link>
@@ -58,14 +92,39 @@ export default function CountsListPage() {
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} /> <KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
<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 doc no…"
className="h-12 w-full pl-11 text-base"
aria-label="Search stock counts"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && counts === null && ( {!error && counts === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -73,43 +132,128 @@ export default function CountsListPage() {
{!error && counts !== null && counts.length === 0 && ( {!error && counts !== null && counts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" /> <ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No counts yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No counts match your search." : "No counts yet."}</p>
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Count <Plus className="size-5" />
</Link> New Count
</Link>
)}
</div> </div>
)} )}
{!error && counts !== null && counts.length > 0 && ( {!error && counts !== null && counts.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{counts.map((c) => ( {counts.map((c) => (
<TableRow key={c.countId}> <div
<TableCell className="px-3 py-3.5"> key={c.countId}
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-medium text-primary hover:underline"> role="link"
{c.docNo} tabIndex={0}
</Link> onClick={(e) => {
</TableCell> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableCell className="px-3 py-3.5">{whCode(c.warehouseId)}</TableCell> router.push(`/dashboard/stock/counts/${c.countId}`)
<TableCell className="px-3 py-3.5">{c.countType}</TableCell> }}
<TableCell className="px-3 py-3.5"> onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/stock/counts/${c.countId}`)
}}
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.docNo}</p>
<CountStatusBadge status={c.status} /> <CountStatusBadge status={c.status} />
</TableCell> </div>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleString()}</TableCell>
</TableRow> <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">{whCode(c.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.countType}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && counts.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-40">Doc No</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-32">Type</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-40">Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{counts.map((c) => (
<TableRow
key={c.countId}
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/stock/counts/${c.countId}`)
}}
>
<TableCell>
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-semibold text-foreground hover:underline">
{c.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{whCode(c.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.countType}</TableCell>
<TableCell>
<CountStatusBadge status={c.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(c.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/stock/counts/${c.countId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${c.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</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> </div>
) )
@@ -2,21 +2,29 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PackageSearch, Search } from "lucide-react" import { Eye, PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand } from "@/types/stock" import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" 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
// since these rows are already loaded client-side, more just slice in immediately.
const MOBILE_PAGE_SIZE_STEP = 10
export default function StockEnquiryPage() { export default function StockEnquiryPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -27,6 +35,8 @@ export default function StockEnquiryPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [warehouseId, setWarehouseId] = useState<number | "All">("All") const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
useEffect(() => { useEffect(() => {
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -56,6 +66,18 @@ export default function StockEnquiryPage() {
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId) .sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
}, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled]) }, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled])
useEffect(() => setPage(1), [search, warehouseId])
const pagination = filtered
? {
page,
pageSize,
totalItems: filtered.length,
totalPages: Math.max(1, Math.ceil(filtered.length / pageSize)),
}
: null
const pagedRows = filtered?.slice((page - 1) * pageSize, page * pageSize) ?? null
const loading = rows === null || items === null || warehouses === null const loading = rows === null || items === null || warehouses === null
return ( return (
@@ -100,9 +122,21 @@ export default function StockEnquiryPage() {
)} )}
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -114,49 +148,126 @@ export default function StockEnquiryPage() {
</div> </div>
)} )}
{!loading && !error && filtered !== null && filtered.length > 0 && ( {!loading && !error && pagedRows !== null && pagedRows.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards, "Show more" instead of pagination. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Item</TableHead> {filtered!.slice(0, page * pageSize).map((row) => {
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">On hand</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">On hold</TableHead>
<TableHead className="h-12 px-3 text-sm">In transit</TableHead>
<TableHead className="h-12 px-3 text-sm">Reserved</TableHead>
<TableHead className="h-12 px-3 text-sm">Valuation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row) => {
const item = itemsById.get(row.itemId) const item = itemsById.get(row.itemId)
const wh = warehousesById.get(row.warehouseId) const wh = warehousesById.get(row.warehouseId)
return ( return (
<TableRow key={`${row.itemId}-${row.warehouseId}`}> <Link
<TableCell className="px-3 py-3.5"> key={`${row.itemId}-${row.warehouseId}`}
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div> href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
<div className="text-sm text-muted-foreground">{item?.name}</div> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
</TableCell> >
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell> <p className="truncate font-semibold text-foreground">{item?.sku ?? `#${row.itemId}`}</p>
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell> <p className="text-sm text-muted-foreground">{item?.name}</p>
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell> <p className="text-sm text-muted-foreground">{wh?.code ?? `#${row.warehouseId}`}</p>
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell> </div>
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
<TableCell className="px-3 py-3.5"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<Link <div>
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`} <p className="text-muted-foreground">On hand</p>
className="font-medium text-primary hover:underline" <p className="font-medium text-foreground">{row.onHand}</p>
> </div>
View <div>
</Link> <p className="text-muted-foreground">Available</p>
</TableCell> <p className="font-semibold text-foreground">{row.available}</p>
</TableRow> </div>
<div>
<p className="text-muted-foreground">On hold</p>
<p className="font-medium text-foreground">{row.onHold}</p>
</div>
<div>
<p className="text-muted-foreground">In transit</p>
<p className="font-medium text-foreground">{row.inTransit}</p>
</div>
<div>
<p className="text-muted-foreground">Reserved</p>
<p className="font-medium text-foreground">{row.reserved}</p>
</div>
</div>
</Link>
) )
})} })}
</TableBody>
</Table> {filtered && page * pageSize < filtered.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setPage((p) => p + 1)}
>
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-28">SKU</TableHead>
<TableHead className="w-48">Item name</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="text-right">On hand</TableHead>
<TableHead className="text-right">Available</TableHead>
<TableHead className="text-right">On hold</TableHead>
<TableHead className="text-right">In transit</TableHead>
<TableHead className="text-right">Reserved</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.map((row) => {
const item = itemsById.get(row.itemId)
const wh = warehousesById.get(row.warehouseId)
return (
<TableRow key={`${row.itemId}-${row.warehouseId}`} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${row.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.onHand}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{row.available}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.onHold}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.inTransit}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.reserved}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View valuation for ${item?.sku ?? row.itemId}`}
title="View"
>
<Eye className="size-4" />
</Link>
</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> </div>
) )
@@ -13,6 +13,7 @@ import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -21,7 +22,10 @@ import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 25 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 StockLedgerPage() { export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -37,6 +41,8 @@ export default function StockLedgerPage() {
const [from, setFrom] = useState("") const [from, setFrom] = useState("")
const [to, setTo] = useState("") const [to, setTo] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -61,7 +67,7 @@ export default function StockLedgerPage() {
from: from || undefined, from: from || undefined,
to: to || undefined, to: to || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize,
}) })
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
@@ -71,10 +77,18 @@ export default function StockLedgerPage() {
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [itemId, warehouseId, from, to, page, activeBranchId, branchingEnabled]) }, [itemId, warehouseId, from, to, page, pageSize, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -141,9 +155,21 @@ export default function StockLedgerPage() {
</div> </div>
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -157,56 +183,128 @@ export default function StockLedgerPage() {
{!loading && !error && entries !== null && entries.length > 0 && ( {!loading && !error && entries !== null && entries.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {entries.map((entry) => {
<TableHead className="h-12 px-3 text-sm">Date</TableHead> const item = itemsById.get(entry.itemId)
<TableHead className="h-12 px-3 text-sm">Item</TableHead> const wh = warehousesById.get(entry.warehouseId)
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> return (
<TableHead className="h-12 px-3 text-sm">Direction</TableHead> <div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Qty</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead> <div className="min-w-0">
<TableHead className="h-12 px-3 text-sm">Value</TableHead> <p className="truncate font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</p>
<TableHead className="h-12 px-3 text-sm">Running balance</TableHead> <p className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
<TableHead className="h-12 px-3 text-sm">Source</TableHead> </div>
</TableRow> <Badge
</TableHeader> variant="outline"
<TableBody> className={cn(
{entries.map((entry) => { "h-6 w-20 shrink-0 justify-center text-sm",
const item = itemsById.get(entry.itemId) entry.direction === "In"
const wh = warehousesById.get(entry.warehouseId) ? "border-transparent bg-success/10 text-success"
return ( : "border-transparent bg-destructive/10 text-destructive"
<TableRow key={entry.ledgerId}> )}
<TableCell className="px-3 py-3.5">{new Date(entry.createdAt).toLocaleString()}</TableCell> >
<TableCell className="px-3 py-3.5">{item?.sku ?? `#${entry.itemId}`}</TableCell> {entry.direction}
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${entry.warehouseId}`}</TableCell> </Badge>
<TableCell className="px-3 py-3.5"> </div>
<Badge
variant="outline"
className={cn(
"h-6 w-20 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty / Unit cost</p>
<p className="font-medium text-foreground">{entry.qtyBase} @ {entry.unitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Value</p>
<p className="font-medium text-foreground">{entry.value.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Running balance</p>
<p className="font-semibold text-foreground">{entry.runningBalance}</p>
</div>
<div>
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">{entry.sourceDocType} #{entry.sourceDocId}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleString()}</p>
</div>
</div>
</div>
)
})}
{pagination && entries.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-40">Date</TableHead>
<TableHead className="w-32">Item</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-28">Direction</TableHead>
<TableHead className="w-24 text-right">Qty</TableHead>
<TableHead className="w-28 text-right">Unit cost</TableHead>
<TableHead className="w-28 text-right">Value</TableHead>
<TableHead className="w-36 text-right">Running balance</TableHead>
<TableHead className="w-36">Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => {
const item = itemsById.get(entry.itemId)
const wh = warehousesById.get(entry.warehouseId)
return (
<TableRow key={entry.ledgerId} className="align-top hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-20 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.qtyBase}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.value.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{entry.runningBalance}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</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> </div>
@@ -95,54 +95,108 @@ export default function ReorderAlertsPage() {
)} )}
{!loading && !error && alerts.length > 0 && ( {!loading && !error && alerts.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
<TableHead className="h-12 px-3 text-sm">Suggested qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => { {alerts.map((alert) => {
const key = `${alert.itemId}-${alert.warehouseId}` const key = `${alert.itemId}-${alert.warehouseId}`
const item = itemsById.get(alert.itemId) const item = itemsById.get(alert.itemId)
const wh = warehousesById.get(alert.warehouseId) const wh = warehousesById.get(alert.warehouseId)
const done = requested.has(key) const done = requested.has(key)
return ( return (
<TableRow key={key}> <div key={key} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5"> <div className="flex items-start gap-2">
<div className="flex items-center gap-2"> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" />
<AlertTriangle className="size-4 text-warning" /> <div className="min-w-0">
<div> <p className="truncate font-medium text-foreground">{item?.sku ?? `#${alert.itemId}`}</p>
<div className="font-medium">{item?.sku ?? `#${alert.itemId}`}</div> <p className="text-sm text-muted-foreground">{item?.name}</p>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</div>
</div> </div>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${alert.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5 font-medium text-destructive">{alert.available}</TableCell> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5">{alert.reorderPoint}</TableCell> <div>
<TableCell className="px-3 py-3.5">{alert.suggestedRequisitionQty}</TableCell> <p className="text-muted-foreground">Available</p>
<TableCell className="px-3 py-3.5"> <p className="font-medium text-destructive">{alert.available}</p>
<Button </div>
type="button" <div>
size="sm" <p className="text-muted-foreground">Reorder pt</p>
variant={done ? "outline" : "default"} <p className="font-medium text-foreground">{alert.reorderPoint}</p>
disabled={done || requesting === key} </div>
onClick={() => handleRequisition(alert)} <div>
> <p className="text-muted-foreground">Suggested</p>
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"} <p className="font-medium text-foreground">{alert.suggestedRequisitionQty}</p>
</Button> </div>
</TableCell> </div>
</TableRow>
<p className="text-sm text-muted-foreground">{wh?.code ?? `#${alert.warehouseId}`}</p>
<Button
type="button"
size="sm"
variant={done ? "outline" : "default"}
className="w-full"
disabled={done || requesting === key}
onClick={() => handleRequisition(alert)}
>
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"}
</Button>
</div>
) )
})} })}
</TableBody> </div>
</Table>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
<TableHead className="h-12 px-3 text-sm">Suggested qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => {
const key = `${alert.itemId}-${alert.warehouseId}`
const item = itemsById.get(alert.itemId)
const wh = warehousesById.get(alert.warehouseId)
const done = requested.has(key)
return (
<TableRow key={key}>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 text-warning" />
<div>
<div className="font-medium">{item?.sku ?? `#${alert.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${alert.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5 font-medium text-destructive">{alert.available}</TableCell>
<TableCell className="px-3 py-3.5">{alert.reorderPoint}</TableCell>
<TableCell className="px-3 py-3.5">{alert.suggestedRequisitionQty}</TableCell>
<TableCell className="px-3 py-3.5">
<Button
type="button"
size="sm"
variant={done ? "outline" : "default"}
disabled={done || requesting === key}
onClick={() => handleRequisition(alert)}
>
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"}
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)} )}
</div> </div>
) )
@@ -5,8 +5,10 @@ import { useParams } from "next/navigation"
import { CheckCircle2, PackageCheck, Truck } from "lucide-react" import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock" import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
import { ItemListItem } from "@/types/master-data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -19,6 +21,7 @@ export default function TransferDetailPage() {
const transferId = Number(params.id) const transferId = Number(params.id)
const [transfer, setTransfer] = useState<StockTransfer | null>(null) const [transfer, setTransfer] = useState<StockTransfer | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [dispatching, setDispatching] = useState(false) const [dispatching, setDispatching] = useState(false)
const [receiving, setReceiving] = useState(false) const [receiving, setReceiving] = useState(false)
@@ -30,6 +33,15 @@ export default function TransferDetailPage() {
stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err))) stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err)))
}, [transferId]) }, [transferId])
useEffect(() => {
itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {})
}, [])
function itemName(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `#${itemId}`
}
async function handleDispatch() { async function handleDispatch() {
setError(null) setError(null)
setDispatching(true) setDispatching(true)
@@ -94,15 +106,15 @@ export default function TransferDetailPage() {
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex flex-col gap-3 sm:flex-row">
{transfer.status === "Draft" && ( {transfer.status === "Draft" && (
<Button size="lg" onClick={handleDispatch} disabled={dispatching}> <Button size="lg" className="w-full sm:w-auto" onClick={handleDispatch} disabled={dispatching}>
<Truck className="size-5" /> <Truck className="size-5" />
{dispatching ? "Dispatching…" : "Dispatch"} {dispatching ? "Dispatching…" : "Dispatch"}
</Button> </Button>
)} )}
{transfer.status === "InTransit" && ( {transfer.status === "InTransit" && (
<Button size="lg" onClick={handleReceive} disabled={receiving}> <Button size="lg" className="w-full sm:w-auto" onClick={handleReceive} disabled={receiving}>
<PackageCheck className="size-5" /> <PackageCheck className="size-5" />
{receiving ? "Receiving…" : "Receive"} {receiving ? "Receiving…" : "Receive"}
</Button> </Button>
@@ -140,26 +152,54 @@ export default function TransferDetailPage() {
</div> </div>
)} )}
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {transfer.lines.map((line) => (
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <div key={line.transferLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 px-3 text-sm">Src bin</TableHead> <p className="font-semibold text-foreground">{itemName(line.itemId)}</p>
<TableHead className="h-12 px-3 text-sm">Dest bin</TableHead> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableHead className="h-12 px-3 text-sm">Qty</TableHead> <div>
</TableRow> <p className="text-muted-foreground">Src bin</p>
</TableHeader> <p className="font-medium text-foreground">{line.srcBinId ?? "—"}</p>
<TableBody> </div>
{transfer.lines.map((line) => ( <div>
<TableRow key={line.transferLineId}> <p className="text-muted-foreground">Dest bin</p>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell> <p className="font-medium text-foreground">{line.destBinId ?? "—"}</p>
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell> </div>
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell> <div>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell> <p className="text-muted-foreground">Qty</p>
</TableRow> <p className="font-medium text-foreground">{line.qty}</p>
))} </div>
</TableBody> </div>
</Table> </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="w-56">Item</TableHead>
<TableHead className="w-28">Src bin</TableHead>
<TableHead className="w-28">Dest bin</TableHead>
<TableHead className="w-24 text-right">Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfer.lines.map((line) => (
<TableRow key={line.transferLineId} className="align-top hover:bg-muted/20">
<TableCell className="py-5 font-semibold text-foreground">{itemName(line.itemId)}</TableCell>
<TableCell className="py-5 text-sm text-muted-foreground">{line.srcBinId ?? "—"}</TableCell>
<TableCell className="py-5 text-sm text-muted-foreground">{line.destBinId ?? "—"}</TableCell>
<TableCell className="py-5 text-right text-sm text-muted-foreground tabular-nums">{line.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div> </div>
) )
} }
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
@@ -8,10 +8,12 @@ import { Plus, Trash2 } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateTransferLineInput } from "@/types/stock" import { CreateTransferLineInput } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data" import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -44,10 +46,12 @@ export default function NewTransferPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [srcBins, setSrcBins] = useState<Bin[]>([]) const [srcBins, setSrcBins] = useState<Bin[]>([])
const [destBins, setDestBins] = useState<Bin[]>([]) const [destBins, setDestBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [branchId, setBranchId] = useState<number | "All">("All")
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null) const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null) const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
const [srcBinId, setSrcBinId] = useState<number | null>(null) const [srcBinId, setSrcBinId] = useState<number | null>(null)
@@ -66,6 +70,26 @@ export default function NewTransferPage() {
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
const visibleWarehouses = useMemo(() => {
const all = warehouses ?? []
if (branchId === "All") return all
const matches = all.filter((w) => w.branchId === branchId)
// Fail open: if no warehouse is actually tagged with this branch (branch scoping isn't
// wired up for warehouses in every tenant), don't leave the picker with nothing to select.
return matches.length > 0 ? matches : all
}, [warehouses, branchId])
// Switching the branch filter can strand a selected warehouse outside the new list.
useEffect(() => {
if (srcWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === srcWarehouseId)) setSrcWarehouseId(null)
if (destWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === destWarehouseId)) setDestWarehouseId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
useEffect(() => { useEffect(() => {
if (!srcWarehouseId) { if (!srcWarehouseId) {
setSrcBins([]) setSrcBins([])
@@ -93,6 +117,10 @@ export default function NewTransferPage() {
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
if (branchId === "All") {
setSubmitError("Select a branch for this transfer.")
return
}
if (!srcWarehouseId || !destWarehouseId) { if (!srcWarehouseId || !destWarehouseId) {
setSubmitError("Select both a source and destination warehouse.") setSubmitError("Select both a source and destination warehouse.")
return return
@@ -122,7 +150,7 @@ export default function NewTransferPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, lines: payloadLines }) const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId, lines: payloadLines })
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`) toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
router.push(`/dashboard/stock/transfers/${transfer.transferId}`) router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
} catch (err) { } catch (err) {
@@ -152,6 +180,27 @@ export default function NewTransferPage() {
{!loading && ( {!loading && (
<> <>
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
<Select<number | "All">
value={branchId}
onValueChange={(v) => setBranchId(v ?? "All")}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All branches</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Required — the branch this transfer belongs to. Also narrows the warehouse choices below.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">From warehouse</Label> <Label className="text-base">From warehouse</Label>
@@ -160,7 +209,7 @@ export default function NewTransferPage() {
<SelectValue placeholder="Select warehouse" /> <SelectValue placeholder="Select warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []).map((w) => ( {visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name} {w.code} — {w.name}
</SelectItem> </SelectItem>
@@ -192,7 +241,7 @@ export default function NewTransferPage() {
<SelectValue placeholder="Select warehouse" /> <SelectValue placeholder="Select warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []) {visibleWarehouses
.filter((w) => w.warehouseId !== srcWarehouseId) .filter((w) => w.warehouseId !== srcWarehouseId)
.map((w) => ( .map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
@@ -205,7 +254,7 @@ export default function NewTransferPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" /> <Plus className="size-5" />
@@ -213,19 +262,13 @@ export default function NewTransferPage() {
</Button> </Button>
</div> </div>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {lines.map((line) => (
<TableHead className="h-12 w-64 px-3 text-sm">Item</TableHead> <div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 w-40 px-3 text-sm">Destination bin</TableHead> <div className="flex items-start gap-2">
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead> <div className="flex-1">
<TableHead className="h-12 w-10 px-3" /> <Label className="text-sm">Item</Label>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" /> <SelectValue placeholder="Select item" />
@@ -238,8 +281,15 @@ export default function NewTransferPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <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">Destination bin</Label>
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}> <Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" /> <SelectValue placeholder="None" />
@@ -252,39 +302,101 @@ export default function NewTransferPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <div>
<Label className="text-sm">Qty</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
step="any" step="any"
inputMode="decimal"
value={line.qty} value={line.qty}
aria-invalid={!!lineErrors[line.key]} aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qty: e.target.value })} onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} /> <FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> </div>
<Trash2 className="size-5" /> ))}
</Button> </div>
</TableCell>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-64 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Destination bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<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>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{destBins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : 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>
</div> </div>
{submitError && ( {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Transfer"} {submitting ? "Creating…" : "Create Transfer"}
</Button> </Button>
</div> </div>
@@ -2,41 +2,75 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeftRight, Plus } from "lucide-react" import { useRouter } from "next/navigation"
import { ArrowLeftRight, Eye, Plus, Search } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockTransferSummary } from "@/types/stock" import { StockTransferSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" 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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { TransferStatusBadge } from "@/components/stock/status-badges" import { TransferStatusBadge } from "@/components/stock/status-badges"
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 TransfersListPage() { export default function TransfersListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null) const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockTransfersApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
])
.then(([t, wh]) => { useEffect(() => setPage(1), [query])
setTransfers(t.items)
setWarehouses(wh.items) useEffect(() => {
setError(null)
stockTransfersApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setTransfers(res.items)
setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [activeBranchId, branchingEnabled]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -47,20 +81,45 @@ export default function TransfersListPage() {
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p> <p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Transfer New Transfer
</Link> </Link>
</div> </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 doc no…"
className="h-12 w-full pl-11 text-base"
aria-label="Search stock transfers"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && transfers === null && ( {!error && transfers === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -68,43 +127,128 @@ export default function TransfersListPage() {
{!error && transfers !== null && transfers.length === 0 && ( {!error && transfers !== null && transfers.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ArrowLeftRight className="size-12 text-muted-foreground" /> <ArrowLeftRight className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No transfers yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No transfers match your search." : "No transfers yet."}</p>
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Transfer <Plus className="size-5" />
</Link> New Transfer
</Link>
)}
</div> </div>
)} )}
{!error && transfers !== null && transfers.length > 0 && ( {!error && transfers !== null && transfers.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">From</TableHead>
<TableHead className="h-12 px-3 text-sm">To</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers.map((t) => ( {transfers.map((t) => (
<TableRow key={t.transferId}> <div
<TableCell className="px-3 py-3.5"> key={t.transferId}
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-medium text-primary hover:underline"> role="link"
{t.docNo} tabIndex={0}
</Link> onClick={(e) => {
</TableCell> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableCell className="px-3 py-3.5">{whCode(t.srcWarehouseId)}</TableCell> router.push(`/dashboard/stock/transfers/${t.transferId}`)
<TableCell className="px-3 py-3.5">{whCode(t.destWarehouseId)}</TableCell> }}
<TableCell className="px-3 py-3.5"> onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/stock/transfers/${t.transferId}`)
}}
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">{t.docNo}</p>
<TransferStatusBadge status={t.status} /> <TransferStatusBadge status={t.status} />
</TableCell> </div>
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleString()}</TableCell>
</TableRow> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">From</p>
<p className="font-medium text-foreground">{whCode(t.srcWarehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">To</p>
<p className="font-medium text-foreground">{whCode(t.destWarehouseId)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && transfers.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-40">Doc No</TableHead>
<TableHead className="w-32">From</TableHead>
<TableHead className="w-32">To</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-40">Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers.map((t) => (
<TableRow
key={t.transferId}
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/stock/transfers/${t.transferId}`)
}}
>
<TableCell>
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-semibold text-foreground hover:underline">
{t.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{whCode(t.srcWarehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{whCode(t.destWarehouseId)}</TableCell>
<TableCell>
<TransferStatusBadge status={t.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/stock/transfers/${t.transferId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${t.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</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> </div>
) )
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useMemo, useState } from "react" import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { BadgeDollarSign } from "lucide-react" import { BadgeDollarSign, Boxes, Layers } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
@@ -19,6 +19,21 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
/** Small tinted stat chip — value + label + icon. Matches the vendor/customer profile pages. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; 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>
)
}
function ValuationContent() { function ValuationContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -53,6 +68,17 @@ function ValuationContent() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [itemId, warehouseId]) }, [itemId, warehouseId])
// 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)
}, [])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId]) const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
const visibleWarehouses = useMemo( const visibleWarehouses = useMemo(
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])), () => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
@@ -126,19 +152,10 @@ function ValuationContent() {
<Skeleton className="h-48 w-full" /> <Skeleton className="h-48 w-full" />
) : ( ) : (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border p-5"> <StatChip icon={Boxes} label="Item" value={item?.sku ?? `#${itemId}`} />
<p className="text-sm text-muted-foreground">Item</p> <StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} />
<p className="text-lg font-semibold">{item?.sku ?? `#${itemId}`}</p> <StatChip icon={BadgeDollarSign} label={`Total value (${valuation.currency})`} value={valuation.totalValue.toFixed(2)} />
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total quantity</p>
<p className="text-lg font-semibold">{valuation.totalQty}</p>
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total value ({valuation.currency})</p>
<p className="text-lg font-semibold">{valuation.totalValue.toFixed(2)}</p>
</div>
</div> </div>
{valuation.layers.length === 0 ? ( {valuation.layers.length === 0 ? (
@@ -147,12 +164,29 @@ function ValuationContent() {
<> <>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border p-5"> <div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Value by FIFO layer</p> <p className="mb-3 text-sm font-medium text-muted-foreground">Stock value by purchase batch</p>
<BarChart <BarChart
labels={valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 layers,
datasets={[{ label: `Value (${valuation.currency})`, data: valuation.layers.map((l) => l.value), backgroundColor: "rgba(59,130,246,0.6)" }]} // Chart.js would otherwise center the lone category across the full width.
barPercentage={0.4} // Trailing blank slots keep the axis/gridlines spanning the full card while
categoryPercentage={0.95} // the real bars sit at the start. Skipped on mobile — there the bars should
// stretch to fill the narrower card instead.
labels={[
...valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })),
...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill("")),
]}
datasets={[
{
label: `Value (${valuation.currency})`,
data: [
...valuation.layers.map((l) => l.value),
...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/> />
</div> </div>
@@ -164,42 +198,63 @@ function ValuationContent() {
{ {
label: `Value (${valuation.currency})`, label: `Value (${valuation.currency})`,
data: valuation.layers.map((l) => l.value), data: valuation.layers.map((l) => l.value),
borderColor: "#3b82f6", borderColor: "rgba(34,197,94,1)",
backgroundColor: "rgba(59,130,246,0.15)", backgroundColor: "rgba(34,197,94,0.15)",
}, },
{ {
label: `Cumulative value (${valuation.currency})`, label: `Cumulative value (${valuation.currency})`,
data: valuation.layers.reduce<number[]>((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []), data: valuation.layers.reduce<number[]>((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []),
borderColor: "#22c55e", borderColor: "rgba(239,68,68,1)",
backgroundColor: "rgba(34,197,94,0.15)", backgroundColor: "rgba(239,68,68,0.15)",
}, },
]} ]}
/> />
</div> </div>
</div> </div>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-2 md:hidden">
<TableRow>
<TableHead className="h-12 px-3 text-sm">Layer</TableHead>
<TableHead className="h-12 px-3 text-sm">Receipt date</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{valuation.layers.map((layer) => ( {valuation.layers.map((layer) => (
<TableRow key={layer.layerId}> <div key={layer.layerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<TableCell className="px-3 py-3.5">{layer.layerId}</TableCell> <div className="flex items-center justify-between gap-2">
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell> <span className="font-semibold text-foreground">Layer #{layer.layerId}</span>
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell> <span className="shrink-0 text-xs text-muted-foreground">{new Date(layer.receiptDate).toLocaleDateString()}</span>
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell> </div>
<TableCell className="px-3 py-3.5">{layer.value.toFixed(2)}</TableCell> <div className="flex items-center justify-between text-muted-foreground">
</TableRow> <span>{layer.qtyRemaining} @ {layer.unitCost.toFixed(2)}</span>
<span className="font-semibold text-foreground">{layer.value.toFixed(2)}</span>
</div>
</div>
))} ))}
</TableBody> </div>
</Table>
{/* 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-28">Layer</TableHead>
<TableHead className="w-40">Receipt date</TableHead>
<TableHead className="w-32 text-right">Qty remaining</TableHead>
<TableHead className="w-32 text-right">Unit cost</TableHead>
<TableHead className="w-32 text-right">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{valuation.layers.map((layer) => (
<TableRow key={layer.layerId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{layer.layerId}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{layer.qtyRemaining}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{layer.unitCost.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{layer.value.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</> </>
)} )}
</> </>
@@ -276,11 +276,11 @@ export default function NewWastagePage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" variant="destructive" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" variant="destructive" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Recording…" : "Record Wastage"} {submitting ? "Recording…" : "Record Wastage"}
</Button> </Button>
</div> </div>
@@ -14,13 +14,16 @@ import { ReasonCode } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function WastagePage() { export default function WastagePage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -32,6 +35,8 @@ export default function WastagePage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All") const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All") const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
useEffect(() => { useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -49,15 +54,23 @@ export default function WastagePage() {
warehouseId: warehouseId === "All" ? undefined : warehouseId, warehouseId: warehouseId === "All" ? undefined : warehouseId,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId, reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
pageSize: 200,
}) })
.then(setRecords) .then(setRecords)
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled]) }, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled])
useEffect(() => setPage(1), [warehouseId, reasonCodeId])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes]) const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes])
const pagination = records
? { page, pageSize, totalItems: records.length, totalPages: Math.max(1, Math.ceil(records.length / pageSize)) }
: null
const pagedRecords = records?.slice((page - 1) * pageSize, page * pageSize) ?? null
const totals = useMemo(() => { const totals = useMemo(() => {
if (!records) return { qty: 0, value: 0 } if (!records) return { qty: 0, value: 0 }
return records.reduce( return records.reduce(
@@ -79,7 +92,7 @@ export default function WastagePage() {
</p> </p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
Record Wastage Record Wastage
</Link> </Link>
@@ -138,9 +151,21 @@ export default function WastagePage() {
</div> </div>
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -152,45 +177,113 @@ export default function WastagePage() {
</div> </div>
)} )}
{!loading && !error && records.length > 0 && ( {!loading && !error && pagedRecords !== null && pagedRecords.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards, "Show more" instead of pagination. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Date</TableHead> {records!.slice(0, page * pageSize).map((r) => {
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</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">Qty wasted</TableHead>
<TableHead className="h-12 px-3 text-sm">Value (LKR)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => {
const item = itemsById.get(r.itemId) const item = itemsById.get(r.itemId)
const wh = warehousesById.get(r.warehouseId) const wh = warehousesById.get(r.warehouseId)
const reason = reasonById.get(r.reasonCodeId) const reason = reasonById.get(r.reasonCodeId)
return ( return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`}> <div key={`${r.adjustmentId}-${r.adjLineId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5"> <p className="truncate font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</p>
<div className="font-medium">{item?.sku ?? `#${r.itemId}`}</div> <p className="text-sm text-muted-foreground">{item?.name}</p>
<div className="text-sm text-muted-foreground">{item?.name}</div> </div>
</TableCell> <Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${r.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
{reason?.description ?? `#${r.reasonCodeId}`} {reason?.description ?? `#${r.reasonCodeId}`}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5 font-medium text-destructive">-{r.qty}</TableCell>
<TableCell className="px-3 py-3.5">{r.value.toFixed(2)}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <div>
<p className="text-muted-foreground">Doc No</p>
<p className="font-medium text-foreground">{r.docNo}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{wh?.code ?? `#${r.warehouseId}`}</p>
</div>
<div>
<p className="text-muted-foreground">Qty wasted</p>
<p className="font-semibold text-destructive">-{r.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Value (LKR)</p>
<p className="font-medium text-foreground">{r.value.toFixed(2)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleString()}</p>
</div>
</div>
</div>
) )
})} })}
</TableBody>
</Table> {records && page * pageSize < records.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPage((p) => p + 1)}>
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">Doc No</TableHead>
<TableHead className="w-40">Date</TableHead>
<TableHead className="w-28">SKU</TableHead>
<TableHead className="w-48">Item name</TableHead>
<TableHead className="w-28">Warehouse</TableHead>
<TableHead className="w-36">Reason</TableHead>
<TableHead className="w-28 text-right">Qty wasted</TableHead>
<TableHead className="w-28 text-right">Value (LKR)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedRecords.map((r) => {
const item = itemsById.get(r.itemId)
const wh = warehousesById.get(r.warehouseId)
const reason = reasonById.get(r.reasonCodeId)
return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${r.warehouseId}`}</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
{reason?.description ?? `#${r.reasonCodeId}`}
</Badge>
</TableCell>
<TableCell className="text-right font-semibold text-destructive tabular-nums">-{r.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.value.toFixed(2)}</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> </div>
) )
+309 -50
View File
@@ -1,27 +1,49 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, Save } from "lucide-react" import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, ShoppingCart, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code" import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data" import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button" 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function VendorDetailPage() { export default function VendorDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const vendorId = Number(params.id) const vendorId = Number(params.id)
const [vendor, setVendor] = useState<Vendor | null>(null) const [vendor, setVendor] = useState<Vendor | null>(null)
@@ -39,6 +61,10 @@ export default function VendorDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
// Profile activity: this vendor's recent goods receipts ("buying").
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
// Every other vendor's code, to de-dupe against when the name edit regenerates this // Every other vendor's code, to de-dupe against when the name edit regenerates this
// vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx). // vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx).
@@ -56,18 +82,22 @@ export default function VendorDetailPage() {
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "") setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
} }
function applyVendor(data: Vendor) {
setVendor(data)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
}
function load() { function load() {
setLoadError(null) setLoadError(null)
vendorsApi vendorsApi
.get(vendorId) .get(vendorId)
.then(({ data, etag: tag }) => { .then(({ data, etag: tag }) => {
setVendor(data) applyVendor(data)
setEtag(tag) setEtag(tag)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setConflict(false) setConflict(false)
}) })
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
@@ -78,6 +108,63 @@ export default function VendorDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [vendorId]) }, [vendorId])
/** Opens the edit dialog fresh from the last-loaded vendor, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (vendor) applyVendor(vendor)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (vendor) applyVendor(vendor)
setEditOpen(false)
}
useEffect(() => {
if (!Number.isFinite(vendorId)) return
grnsApi.list({ vendorId, pageSize: 100 }).then((res) => setGrns(res.items)).catch(() => setGrns([]))
}, [vendorId])
const totalPurchased = useMemo(() => (grns ?? []).reduce((sum, g) => sum + g.totalValue, 0), [grns])
const recentGrns = useMemo(
() => (grns ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[grns]
)
const [grnPage, setGrnPage] = useState(1)
const [grnPageSize, setGrnPageSize] = useState(GRN_PAGE_SIZE_OPTIONS[0])
const [mobileGrnCount, setMobileGrnCount] = useState(GRN_PAGE_SIZE_OPTIONS[0])
const grnPagination = {
page: grnPage,
pageSize: grnPageSize,
totalItems: recentGrns.length,
totalPages: Math.max(1, Math.ceil(recentGrns.length / grnPageSize)),
}
const pagedGrns = recentGrns.slice((grnPage - 1) * grnPageSize, grnPage * grnPageSize)
const monthlyPurchases = useMemo(() => {
const byMonth = new Map<string, number>()
for (const g of grns ?? []) {
const key = g.createdAt.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + g.totalValue)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [grns])
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
// blessed way to do it once (rather than on every render) is a one-time init function.
// 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() { async function handleSave() {
setSaveError(null) setSaveError(null)
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
@@ -87,11 +174,13 @@ export default function VendorDetailPage() {
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0 || !etag) return if (Object.keys(nextErrors).length > 0 || !etag) return
if (!vendor) return
setSaving(true) setSaving(true)
try { try {
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency }, etag) const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag)
setVendor(result.data) setVendor(result.data)
setEtag(result.etag) setEtag(result.etag)
setEditOpen(false)
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`) toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
} catch (err) { } catch (err) {
const code = (err as { code?: string })?.code const code = (err as { code?: string })?.code
@@ -99,6 +188,7 @@ export default function VendorDetailPage() {
setConflict(true) setConflict(true)
setSaveError(errorMessage(err)) setSaveError(errorMessage(err))
setSaving(false) setSaving(false)
setEditOpen(false)
return return
} }
const fe = fieldErrors(err) const fe = fieldErrors(err)
@@ -147,6 +237,14 @@ export default function VendorDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Link
href="/dashboard/vendors"
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 Vendors
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
@@ -166,14 +264,31 @@ export default function VendorDetailPage() {
</div> </div>
</div> </div>
<Button <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
size="lg" <Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
variant={vendor.status === "Active" ? "destructive" : "success"} <Pencil className="size-4" />
onClick={handleToggleStatus} Edit
disabled={togglingStatus} </Button>
> <Button
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"} size="lg"
</Button> variant={vendor.status === "Active" ? "destructive" : "success"}
className="w-full sm:w-auto"
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
icon={Truck}
label="Last delivery"
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
/>
</div> </div>
{conflict && ( {conflict && (
@@ -192,41 +307,185 @@ export default function VendorDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div> <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 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<Label className="text-base">Code (auto-generated)</Label> <div className="flex items-center gap-2">
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" /> <ShoppingCart className="size-4.5 text-primary" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} /> <h2 className="text-base font-semibold text-foreground">Monthly purchases</h2>
</div>
{grns === null ? (
<Skeleton className="h-56 w-full" />
) : monthlyPurchases.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={[
...monthlyPurchases.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill("")),
]}
datasets={[
{
label: "Purchased (LKR)",
data: [
...monthlyPurchases.map(([, v]) => v),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No purchases recorded yet.</p>
)}
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label> <div className="flex flex-col gap-3 rounded-xl border p-5">
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> <h2 className="text-base font-semibold text-foreground">Recent purchases (GRNs)</h2>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> {grns === null ? (
</div> <div className="overflow-hidden rounded-xl border">
<div className="flex flex-col gap-2"> <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Label className="text-base">Payment terms</Label> <Skeleton className="h-3.5 w-16" />
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} /> <Skeleton className="ml-auto h-3.5 w-16" />
</div> </div>
<div className="flex flex-col gap-2"> {Array.from({ length: 4 }).map((_, i) => (
<Label className="text-base">Tax registration</Label> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} /> <Skeleton className="size-9 shrink-0 rounded-lg" />
</div> <div className="flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-2"> <Skeleton className="h-4 w-40" />
<Label className="text-base">Currency</Label> <Skeleton className="h-3 w-24" />
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} /> </div>
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} /> <Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received from this vendor yet.</p>
) : (
<>
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
<div className="flex flex-col gap-2 md:hidden">
{recentGrns.slice(0, mobileGrnCount).map((g) => (
<Link
key={g.grnId}
href={`/dashboard/receiving/grn/${g.grnId}`}
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">{g.docNo}</span>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{g.lineCount} line{g.lineCount === 1 ? "" : "s"}</span>
<span className="font-medium text-foreground">LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</Link>
))}
{mobileGrnCount < recentGrns.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setMobileGrnCount((c) => c + GRN_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>Document</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedGrns.map((g) => (
<TableRow key={g.grnId} className="align-top hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<Link href={`/dashboard/receiving/grn/${g.grnId}`} className="font-semibold text-foreground hover:underline">
{g.docNo}
</Link>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{g.lineCount}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">
LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={grnPagination}
onPageChange={setGrnPage}
pageSizeOptions={GRN_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setGrnPage(1); setGrnPageSize(size) }}
/>
</div>
</>
)}
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/vendors")}> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
Cancel <DialogHeader>
</Button> <DialogTitle>Edit {vendor.code}</DialogTitle>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}> <DialogDescription>Update this vendor&apos;s details.</DialogDescription>
<Save className="size-5" /> </DialogHeader>
{saving ? "Saving…" : "Save changes"} <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
</Button> <div className="flex flex-col gap-2">
</div> <Label className="text-base">Code (auto-generated)</Label>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => handleNameChange(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">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</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> </div>
) )
} }
+255 -127
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -21,7 +22,6 @@ import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
@@ -36,12 +36,17 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25 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 VendorsPage() { export default function VendorsPage() {
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null) const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -49,6 +54,7 @@ export default function VendorsPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -63,6 +69,7 @@ export default function VendorsPage() {
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([]) const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : "" const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
@@ -77,15 +84,21 @@ export default function VendorsPage() {
function load() { function load() {
vendorsApi vendorsApi
.list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE }) .list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize })
.then((res) => { .then((res) => {
setVendors(res.items) setVendors(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [query, status, page]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, page, pageSize])
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
@@ -104,11 +117,16 @@ export default function VendorsPage() {
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() { function resetForm() {
setName("") setName("")
setTerms("") setTerms("")
setTaxReg("") setTaxReg("")
setCurrency("LKR") setCurrency("LKR")
setBranchFilterId(null)
setErrors({}) setErrors({})
} }
@@ -116,12 +134,13 @@ export default function VendorsPage() {
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required" if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required" if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
setSubmitting(true) setSubmitting(true)
try { try {
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency }) const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`) toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false) setOpen(false)
resetForm() resetForm()
@@ -198,6 +217,22 @@ export default function VendorsPage() {
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} /> <Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} /> <FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field> </Field>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="v-branch">Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger id="v-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> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -239,9 +274,21 @@ export default function VendorsPage() {
)} )}
{!error && vendors === null && ( {!error && vendors === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -257,128 +304,209 @@ export default function VendorsPage() {
{!error && visibleVendors !== null && visibleVendors.length > 0 && ( {!error && visibleVendors !== null && visibleVendors.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {visibleVendors.map((v) => (
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <div
<TableHead className="h-12 px-3 text-sm">Name</TableHead> key={v.vendorId}
<TableHead className="h-12 px-3 text-sm">Terms</TableHead> role="link"
<TableHead className="h-12 px-3 text-sm">Currency</TableHead> tabIndex={0}
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>} onClick={(e) => {
<TableHead className="h-12 px-3 text-sm">Status</TableHead> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> router.push(`/dashboard/vendors/${v.vendorId}`)
</TableRow> }}
</TableHeader> onKeyDown={(e) => {
<TableBody> if (e.key === "Enter") router.push(`/dashboard/vendors/${v.vendorId}`)
{visibleVendors.map((v) => ( }}
<TableRow key={v.vendorId}> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell> >
<TableCell className="px-3 py-3.5">{v.name}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{v.currency}</TableCell> <p className="truncate font-semibold text-foreground">{v.code}</p>
<p className="text-sm text-muted-foreground">{v.name}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Terms</p>
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Currency</p>
<p className="font-medium text-foreground">{v.currency}</p>
</div>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5"> <div className="col-span-2">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(v.branchId)}</Badge> <p className="text-muted-foreground">Branch</p>
</TableCell> <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(v.branchId)}
<TableCell className="px-3 py-3.5"> </Badge>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Dialog>
<DialogTrigger
render={<Button variant="ghost" size="icon-sm" aria-label={`View ${v.code}`} />}
>
<Eye className="size-4" />
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>{v.code}</DialogTitle>
<DialogDescription>{v.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-base">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Payment terms</span>
<span className="font-medium">{v.terms ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Tax registration</span>
<span className="font-medium">{v.taxReg ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Currency</span>
<span className="font-medium">{v.currency}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Status</span>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Created</span>
<span className="font-medium">{new Date(v.createdAt).toLocaleString()}</span>
</div>
</div>
<div className="flex justify-center pt-2">
<DialogClose render={<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" />}>Close</DialogClose>
</div>
</DialogContent>
</Dialog>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
/>
</AlertDialog>
</div> </div>
</TableCell> )}
</TableRow> </div>
))}
</TableBody>
</Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} <div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`}
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 ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && visibleVendors.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>Name</TableHead>
<TableHead>Terms</TableHead>
<TableHead>Currency</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead>Status</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleVendors.map((v) => (
<TableRow
key={v.vendorId}
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/vendors/${v.vendorId}`)
}}
>
<TableCell>
<Link href={`/dashboard/vendors/${v.vendorId}`} className="font-semibold text-foreground hover:underline">
{v.code}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.currency}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell>
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(v.branchId)}</Badge>
</TableCell>
)}
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${v.code}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`}
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 ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
/>
</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> </div>

Some files were not shown because too many files have changed in this diff Show More