Compare commits

..

47 Commits

Author SHA1 Message Date
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
Sasanka cea433e320 product and sales fixers 2026-09-30 17:06:28 +05:30
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
ImanThiyanga 9fd290e256 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m38s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m1s
2026-09-25 09:03:39 +00:00
ImanThiyanga 20ecfdc0b2 Merge pull request 'fixing recipts in sales' (#90) from sales-changes into Dev
Reviewed-on: #90
2026-09-25 09:03:21 +00:00
ImanThiyanga dd82f5b5c4 Merge branch 'Dev' into sales-changes 2026-09-25 09:03:15 +00:00
ImanThiyanga ce55d647b5 Merge pull request 'fron build failure fix' (#89) from Dev into production
Reviewed-on: #89
2026-09-25 06:58:55 +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
218 changed files with 98333 additions and 6553 deletions
+25 -39
View File
@@ -1,4 +1,3 @@
# test
name: CI/CD - ERP Core Monorepo
on:
@@ -9,8 +8,8 @@ on:
- closed
jobs:
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
@@ -21,69 +20,58 @@ jobs:
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop backend service
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix backend ownership and permissions
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart backend service
- name: Restart service
run: systemctl restart erpcore.service
- name: Verify backend
- name: Verify
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:
name: Build & Deploy ERP Core Frontend
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
@@ -96,11 +84,9 @@ jobs:
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
- name: Remove old images
run: docker image prune -f
- name: Verify frontend
- name: Verify
run: |
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"
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -30,6 +30,22 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
}}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -14,11 +14,60 @@ public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
@@ -29,8 +78,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -0,0 +1,44 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
/// only reachable through the authenticated items API — never a static path. Only uploadable
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
/// after the flag is switched off (reads are never gated).
/// </summary>
public class ItemImage
{
public int ItemImageId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
/// <summary>Display order; the lowest is the item's main image.</summary>
public int SortOrder { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
}
@@ -31,6 +31,26 @@ public class ProductConfig
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
/// <summary>
/// Lets the item create screen add a new item variant (item type) on the spot, instead
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
/// </summary>
public bool CustomVariantsEnabled { get; set; }
/// <summary>
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
/// </summary>
public bool ItemImagesEnabled { get; set; }
/// <summary>
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
/// themselves are managed under Products, not here.
/// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -0,0 +1,61 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
/// form defaults to it; when off, a real customer must be picked and the cash customer is
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
/// </summary>
public class SalesConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool AllowCashCustomer { get; set; }
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
/// off and on again reuses the same customer (and its sales history).</summary>
public int? CashCustomerId { get; set; }
public Customer? CashCustomer { get; set; }
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowFreeItems { get; set; } = true;
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
/// column); when off, the column is hidden and a discount above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
public bool AllowOverStockSale { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
/// and every sales invoice (header and lines) is forced onto it server-side (see
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+6
View File
@@ -15,6 +15,12 @@ public class User
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
/// <summary>
/// Username prefix of a permanently deleted login whose row is kept only because history
/// (audit log, documents) still references it. Such rows are hidden from user management.
/// </summary>
public const string DeletedUsernamePrefix = "deleted-user-";
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Custom warranty period master — a coverage length offered to items alongside the fixed
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
/// <para>
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
/// deactivating a period only stops it being offered — items already using it keep it.
/// The length is immutable (no PUT); a different length is a different row.
/// </para>
/// </summary>
public class WarrantyPeriod
{
public int WarrantyPeriodId { get; set; }
public int Months { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
/// </summary>
public sealed class UpdateSalesConfigRequest
{
[Required] public bool? AllowCashCustomer { get; set; }
[Required] public bool? AllowFreeItems { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowOverStockSale { get; set; }
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
}
@@ -23,7 +23,12 @@ public sealed record DashboardStatsDto(
int PendingCounts,
int OpenRfqs,
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>
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>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
@@ -0,0 +1,7 @@
namespace ERPCore.Dtos.Items;
/// <summary>An item photo's metadata. The bytes are served by
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
public sealed record ItemImageDto(
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
int SortOrder, DateTime UploadedAt);
@@ -0,0 +1,27 @@
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);
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
public enum ItemStockFilter
{
InStock,
OutOfStock,
LowStock
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>A new custom length, always in months (the client converts years).</summary>
public sealed class CreateWarrantyPeriodRequest
{
[Required] public int? Months { get; set; }
}
public sealed class UpdateWarrantyPeriodStatusRequest
{
[Required] public EntityStatus Status { get; set; }
}
@@ -44,6 +44,14 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
/// <summary>The unit price a new line would get if left blank — same rule as on save
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
@@ -0,0 +1,25 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ItemImageConfiguration : IEntityTypeConfiguration<ItemImage>
{
public void Configure(EntityTypeBuilder<ItemImage> builder)
{
builder.ToTable("item_images");
builder.HasKey(i => i.ItemImageId);
builder.Property(i => i.OriginalFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.StoredFileName).IsRequired().HasMaxLength(260);
builder.Property(i => i.RelativePath).IsRequired().HasMaxLength(500);
builder.Property(i => i.ContentType).IsRequired().HasMaxLength(200);
builder.Property(i => i.UploadedAt).IsRequired();
builder.HasOne(i => i.Item).WithMany()
.HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(i => new { i.ItemId, i.SortOrder });
}
}
@@ -29,7 +29,9 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -0,0 +1,49 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton sales-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
/// </summary>
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
{
public void Configure(EntityTypeBuilder<SalesConfig> builder)
{
builder.ToTable("sales_config", t =>
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.AllowCashCustomer).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
// instead of being swallowed by the database default.
builder.Property(c => c.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.CashCustomer)
.WithMany()
.HasForeignKey(c => c.CashCustomerId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
{
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
{
builder.ToTable("warranty_periods", t =>
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
builder.HasKey(p => p.WarrantyPeriodId);
builder.Property(p => p.Months).IsRequired();
builder.HasIndex(p => p.Months).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasIndex(p => p.Status);
}
}
@@ -46,6 +46,7 @@ public static class DataSeeder
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -106,7 +107,10 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false
});
return true;
}
@@ -128,6 +132,18 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
/// </summary>
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
@@ -43,6 +45,7 @@ public class ErpDbContext : DbContext
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sales_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
AllowCashCustomer = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
CashCustomerId = table.Column<int>(type: "integer", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_config", x => x.ConfigId);
table.CheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_sales_config_customers_CashCustomerId",
column: x => x.CashCustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sales_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_config_CashCustomerId",
table: "sales_config",
column: "CashCustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_config_UpdatedBy",
table: "sales_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowFreeItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowFreeItems",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowFreeItems",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowOverStockSale : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowOverStockSale",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowOverStockSale",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigWarehouseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowWarehouseSelection",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<int>(
name: "DefaultWarehouseId",
table: "sales_config",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId");
migrationBuilder.AddForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropColumn(
name: "AllowWarehouseSelection",
table: "sales_config");
migrationBuilder.DropColumn(
name: "DefaultWarehouseId",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigCustomVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CustomVariantsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomVariantsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ItemImagesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "item_images",
columns: table => new
{
ItemImageId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<int>(type: "integer", nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
UploadedBy = table.Column<int>(type: "integer", nullable: false),
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
table.ForeignKey(
name: "FK_item_images_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_item_images_ItemId_SortOrder",
table: "item_images",
columns: new[] { "ItemId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "item_images");
migrationBuilder.DropColumn(
name: "ItemImagesEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCustomWarrantyPeriods : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.AddColumn<bool>(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "warranty_periods",
columns: table => new
{
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Months = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Months",
table: "warranty_periods",
column: "Months",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Status",
table: "warranty_periods",
column: "Status");
// Carry over any periods already saved on the config (hand-added — EF would have
// dropped the column first and lost them). Out-of-range values are skipped rather
// than failing the check constraint.
migrationBuilder.Sql("""
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
SELECT DISTINCT m, 'Active', now()
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
WHERE m BETWEEN 1 AND 600
ON CONFLICT ("Months") DO NOTHING;
""");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.Sql("""
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
'{}'::integer[]);
""");
migrationBuilder.DropTable(
name: "warranty_periods");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowDiscounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowDiscounts",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowDiscounts",
table: "sales_config");
}
}
}
@@ -1831,6 +1831,56 @@ namespace ERPCore.Migrations
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.Property<int>("ItemImageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("StoredFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UploadedBy")
.HasColumnType("integer");
b.HasKey("ItemImageId");
b.HasIndex("ItemId", "SortOrder");
b.ToTable("item_images", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<int>("ReorderId")
@@ -3252,6 +3302,21 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("CustomVariantsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("CustomWarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemImagesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemTypesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -4337,6 +4402,68 @@ namespace ERPCore.Migrations
b.ToTable("hr_salary_components", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("AllowCashCustomer")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowDiscounts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowOverStockSale")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowWarehouseSelection")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("CashCustomerId")
.HasColumnType("integer");
b.Property<int?>("DefaultWarehouseId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("CashCustomerId");
b.HasIndex("DefaultWarehouseId");
b.HasIndex("UpdatedBy");
b.ToTable("sales_config", null, t =>
{
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Property<int>("SalesDayEndId")
@@ -6593,6 +6720,43 @@ namespace ERPCore.Migrations
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
{
b.Property<int>("WarrantyPeriodId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("WarrantyPeriodId");
b.HasIndex("Months")
.IsUnique();
b.HasIndex("Status");
b.ToTable("warranty_periods", null, t =>
{
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{
b.Property<int>("WorkShiftId")
@@ -7145,6 +7309,17 @@ namespace ERPCore.Migrations
b.Navigation("TaxRate");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -7835,6 +8010,30 @@ namespace ERPCore.Migrations
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
.WithMany()
.HasForeignKey("CashCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
.WithMany()
.HasForeignKey("DefaultWarehouseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("DefaultWarehouse");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
+3
View File
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IItemImageService, ItemImageService>();
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
@@ -95,6 +97,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
+48 -2
View File
@@ -26,6 +26,7 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IReorderService _reorder;
// 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.
private const int OverdueAgeDays = 30;
// Monthly Purchases chart window: the current month plus the previous 11.
private const int ChartMonths = 12;
public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IReorderService reorder)
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
{
_layers = layers;
_grns = grns;
@@ -47,6 +51,7 @@ public sealed class DashboardService : IDashboardService
_rfqs = rfqs;
_leaveRequests = leaveRequests;
_salesInvoices = salesInvoices;
_purchaseInvoices = purchaseInvoices;
_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))
.Pagination.TotalItems;
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
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)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
}
catch (DbUpdateException)
{
// Every save a user makes writes an AuditLog row against their UserId, so almost any
// real user is still referenced by history and can't be hard-deleted. The login is
// still permanently removed: the row is kept only as a tombstone so historical
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
// Update() flips the entity back from Deleted, or the next save would retry the delete.
_users.Update(user);
user.Username = User.DeletedUsernamePrefix + user.UserId;
user.Email = null;
user.RoleId = null;
user.Status = EntityStatus.Inactive;
await _uow.SaveChangesAsync(ct);
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
}
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
@@ -24,10 +24,16 @@ public interface IFifoCostingService
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction.
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
/// earlier over-stock sale are settled against issuable stock first.
/// </summary>
/// <param name="allowShortfall">
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false);
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -0,0 +1,16 @@
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
public interface IItemImageService
{
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
}
@@ -15,6 +15,13 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default);
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
@@ -9,5 +9,4 @@ public interface IProductConfigService
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<ProductConfigDto>> UpdateAsync(
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
@@ -0,0 +1,14 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales configuration business logic. Singleton.</summary>
public interface ISalesConfigService
{
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -12,6 +12,19 @@ public interface ISalesDomainService
bool requireCashierUser,
CancellationToken ct = default);
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, 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<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -0,0 +1,16 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Custom warranty period master. Never hard-deleted (FR-MD-08).</summary>
public interface IWarrantyPeriodService
{
Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default);
/// <summary>1–600 months, not a default length. Re-adding an inactive length reactivates it;
/// an active duplicate is a 409.</summary>
Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default);
Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,131 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
/// images added earlier remain manageable.
/// </summary>
public sealed class ItemImageService : IItemImageService
{
public const int MaxImagesPerItem = 10;
public const long MaxSizeBytes = 5 * 1024 * 1024;
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".png"] = "image/png",
[".webp"] = "image/webp"
};
private readonly IRepository<ItemImage> _images;
private readonly IRepository<Item> _items;
private readonly IProductConfigService _config;
private readonly IFileStorageService _storage;
private readonly IUnitOfWork _uow;
public ItemImageService(
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
IFileStorageService storage, IUnitOfWork uow)
{
_images = images;
_items = items;
_config = config;
_storage = storage;
_uow = uow;
}
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
{
await EnsureItemExistsAsync(itemId, ct);
return await _images.Query().AsNoTracking()
.Where(i => i.ItemId == itemId)
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
.ToListAsync(ct);
}
public async Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
{
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
throw new DomainException(ErrorCodes.ConfigDisabled,
"Item images are disabled in the product configuration.", 422);
await EnsureItemExistsAsync(itemId, ct);
var extension = Path.GetExtension(fileName);
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
if (content.Length > MaxSizeBytes)
throw new DomainException(ErrorCodes.FileTooLarge,
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
if (existing.Count >= MaxImagesPerItem)
throw new DomainException(ErrorCodes.Validation,
$"An item can have at most {MaxImagesPerItem} images.", 422);
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
var image = new ItemImage
{
ItemId = itemId,
OriginalFileName = Path.GetFileName(fileName),
StoredFileName = storedFileName,
RelativePath = relativePath,
ContentType = expectedContentType,
SizeBytes = sizeBytes,
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
UploadedBy = actorUserId,
UploadedAt = DateTime.UtcNow
};
await _images.AddAsync(image, ct);
await _uow.SaveChangesAsync(ct);
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
image.SizeBytes, image.SortOrder, image.UploadedAt);
}
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
{
var image = await _images.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
}
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
{
var image = await _images.Query()
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
_images.Remove(image);
await _uow.SaveChangesAsync(ct);
// File removed only after the row is gone, so a failed save never orphans a row.
await _storage.DeleteAsync(image.RelativePath, ct);
}
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
}
}
+123 -8
View File
@@ -1,4 +1,5 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
@@ -35,6 +36,7 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config;
@@ -52,6 +54,7 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches,
IRepository<Serial> serials,
IProductConfigService config,
@@ -68,6 +71,7 @@ public sealed class ItemService : IItemService
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches;
_serials = serials;
_config = config;
@@ -108,6 +112,101 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
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);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
var layers = _stockLayers.Query();
switch (stock)
{
case ItemStockFilter.InStock:
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
break;
case ItemStockFilter.OutOfStock:
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
break;
case ItemStockFilter.LowStock:
q = q.Where(i => i.ReorderSettings.Any(rs =>
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
break;
}
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 (negative = sold beyond stock), every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
// reduced in memory rather than a "first per group" query EF can't always translate.
var grnCandidates = await _grnLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
.ToListAsync(ct);
var lastPurchaseByItem = grnCandidates
.GroupBy(x => x.ItemId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
// read off the stock ledger — the one place every sale channel posts uniformly.
var cutoff = DateTime.UtcNow.AddDays(-30);
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
var salesByItem = await _stockLedger.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
var rows = page.Select(i =>
{
var stock = stockByItem.GetValueOrDefault(i.ItemId);
var isLowStock = i.ReorderSettings.Any(rs =>
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
return new ItemOverviewDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
salesByItem.GetValueOrDefault(i.ItemId));
}).ToList();
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
@@ -135,7 +234,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
var item = new Item
{
@@ -212,7 +311,8 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -426,18 +526,33 @@ public sealed class ItemService : IItemService
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
/// just the shared presets, and an item may also carry a length of its own entered on the
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
/// is also accepted, so switching the feature off never blocks editing an item that already
/// uses a custom length. A non-warranty item carries none — any value sent for one is
/// silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
private static int? ValidateWarrantyPeriod(
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
{
if (warranty != Warranty.Warranty) return null;
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
var valid = warrantyPeriodMonths is not null
&& (warrantyPeriodMonths == currentMonths
|| (config.CustomWarrantyPeriodsEnabled
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
if (!valid)
throw new DomainException(
ErrorCodes.Validation,
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
config.CustomWarrantyPeriodsEnabled
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
422);
return warrantyPeriodMonths;
}
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
// Custom variants live in the item-variants section - switching product variants
// off switches this off too, so it can never be left on by itself.
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
// Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -0,0 +1,120 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
/// generic "Cash Customer" record — created the first time cash sales are allowed.
/// </summary>
public sealed class SalesConfigService : ISalesConfigService
{
private const string CashCustomerCode = "CUST-CASH";
private const string CashCustomerName = "Cash Customer";
private readonly IRepository<SalesConfig> _config;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesConfigService(
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
// later throw would otherwise leave the row half-updated.
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
if (request.DefaultWarehouseId is int defaultWarehouseId
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
if (config.AllowCashCustomer && config.CashCustomerId is null)
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
config.AllowFreeItems = request.AllowFreeItems!.Value;
config.AllowDiscounts = request.AllowDiscounts!.Value;
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
// creating a new one on the deployment's default branch.
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
{
var existingId = await _customers.Query().AsNoTracking()
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var customer = new Customer
{
CustomerCode = CashCustomerCode,
CustomerType = CustomerType.WalkIn,
Name = CashCustomerName,
DisplayName = CashCustomerName,
CreditLimit = 0m,
CreditDays = 0,
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _customers.AddAsync(customer, ct);
await _uow.SaveChangesAsync(ct);
return customer.CustomerId;
}
private static SalesConfigDto Map(SalesConfig c) => new(
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
}
+45 -1
View File
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Item> _items;
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig;
public SalesDomainService(
IRepository<Customer> customers,
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<User> users,
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing)
ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig)
{
_customers = customers;
_warehouses = warehouses;
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
_items = items;
_taxRates = taxRates;
_pricing = pricing;
_salesConfig = salesConfig;
}
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
// The generic cash customer is only usable while Sales Configuration allows it.
var cashConfig = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
.FirstOrDefaultAsync(ct);
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (requireCashierUser)
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
}
}
// A missing config row means the option predates it — free items stay allowed.
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowFreeItems)
.FirstOrDefaultAsync(ct) ?? true;
// A missing config row means the option predates it — discounts stay allowed.
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowDiscounts)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
{
var config = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
.FirstOrDefaultAsync(ct);
if (config is null) return null;
// A saved default sales warehouse always wins — sales use only that warehouse.
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
if (config.AllowWarehouseSelection) return null;
// The FK is SetNull on delete, so the default can disappear after it was configured.
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
}
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.AllowOverStockSale)
.FirstOrDefaultAsync(ct);
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, 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);
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);
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -83,6 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
@@ -119,6 +123,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
@@ -154,9 +159,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return _mapping.MapInvoice(invoice);
}
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
// and every line use the configured default sales warehouse.
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
foreach (var line in lines) line.WarehouseId = forced;
return forced;
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now);
if (batchId is not null)
{
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path. No LINQ is
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
// Return consumes it directly (see PurchaseReturnService), never through this
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
// reaches the database intact.
var locked = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
{
foreach (var layer in open)
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
layer.QtyRemaining -= settle;
shortfall.QtyRemaining += settle;
}
}
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase)
if (available < qtyBase && !allowShortfall)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
// below zero), costed at the item's last known unit cost — the next issue from this
// warehouse settles it against the stock received in the meantime (see above).
if (remaining > 0)
{
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
var shortfall = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
QtyReceived = 0m,
QtyRemaining = -remaining,
UnitCost = unitCost,
ReceiptDate = now
};
await _layers.AddAsync(shortfall, ct);
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
}
return segments;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
return await layers.Where(l => l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? 0m;
}
public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -0,0 +1,78 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Custom warranty period master — shared presets offered on every item's form on top of
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
/// item may also carry a one-off length of its own. Nothing references these rows (items
/// store months), so deactivating is always safe and there is no in-use check.
/// </summary>
public sealed class WarrantyPeriodService : IWarrantyPeriodService
{
public const int MaxMonths = 600;
private readonly IRepository<WarrantyPeriod> _periods;
private readonly IUnitOfWork _uow;
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
{
_periods = periods;
_uow = uow;
}
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
{
var q = _periods.Query().AsNoTracking();
if (status is not null) q = q.Where(p => p.Status == status);
return await q.OrderBy(p => p.Months)
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
{
if (months < 1 || months > MaxMonths)
throw new DomainException(ErrorCodes.Validation,
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
if (WarrantyPeriods.AllowedMonths.Contains(months))
throw new ConflictException($"{months} months is already a default warranty period.");
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
if (existing is not null)
{
if (existing.Status == EntityStatus.Active)
throw new ConflictException($"A {months}-month warranty period already exists.");
// Unique by length: re-adding a deactivated one brings it back rather than failing.
existing.Status = EntityStatus.Active;
existing.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(existing);
}
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _periods.AddAsync(period, ct);
await _uow.SaveChangesAsync(ct);
return Map(period);
}
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
{
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
period.Status = status;
period.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
}
+1 -1
View File
@@ -3,6 +3,6 @@
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": ""
"BaseUrl": "http://localhost:6703"
}
}
+3 -2
View File
@@ -20,8 +20,9 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=6702
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
RUN npm prune --production && npm cache clean --force
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
@@ -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 {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
HelpCircle,
Inbox,
@@ -33,12 +31,16 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary",
@@ -288,98 +290,104 @@ export default function HelpPage() {
)}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((t) => (
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{t.subject}
</Link>
</TableCell>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => (
<Link
key={t.ticketId}
href={`/dashboard/help/tickets/${t.ticketId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{isAdmin && (
<div>
<p className="text-muted-foreground">Submitted By</p>
<p className="font-medium text-foreground">{t.createdByName}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Messages</p>
<p className="font-medium text-foreground">{t.messageCount}</p>
</div>
<div>
<p className="text-muted-foreground">Last Updated</p>
<p className="font-medium text-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
</Link>
))}
{totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> tickets
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
{isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem>
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
{ticket.messages.map((m) => (
<div
key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-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">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span>
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3}
/>
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
<Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"}
</Button>
</div>
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div>
<div className="flex 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>
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
@@ -148,47 +148,95 @@ export default function AttendanceBatchDetailPage() {
</div>
)}
<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>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{records.map((r) => (
<div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
<p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">In / Out</p>
<p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Working Hours</p>
<p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
</div>
<div>
<p className="text-muted-foreground">Late</p>
<p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Row status</p>
<p className="font-medium text-foreground">{r.attendanceStatus}</p>
</div>
</div>
{!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-2 border-t pt-3">
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.attendanceRecordId}>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</TableCell>
{!locked && (
<TableCell className="px-3 py-3.5">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</TableCell>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Download, Eye, Plus, Upload } from "lucide-react"
import { useRouter } from "next/navigation"
import { Download, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map"
@@ -21,6 +21,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
@@ -30,10 +33,13 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
}
export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("")
@@ -43,11 +49,17 @@ export default function AttendanceBatchesPage() {
function load() {
attendanceApi
.list({ page, pageSize: PAGE_SIZE })
.list({ page, pageSize })
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
.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() {
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>
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div>
<div className="flex gap-2">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}>
<div className="flex flex-col gap-2 sm:flex-row">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<Download className="size-4" />Download template
</a>
<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">
<DialogHeader className="items-center text-center">
<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>
)}
{!error && batches !== null && batches.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{batches.map((b) => (
<TableRow key={b.attendanceUploadBatchId}>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
</TableCell>
</TableRow>
<div
key={b.attendanceUploadBatchId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.docNo}</p>
<p className="text-sm text-muted-foreground">
{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}
</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Rows</p>
<p className="font-medium text-foreground">{b.rowCountTotal}</p>
</div>
<div>
<p className="text-muted-foreground">Duplicates/Errors</p>
<p className="font-medium text-foreground">{b.rowCountDuplicate} / {b.rowCountError}</p>
</div>
</div>
</div>
))}
</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>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
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() {
return (
<Suspense>
<EmployeeDetailPageContent />
</Suspense>
)
}
function EmployeeDetailPageContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const employeeId = Number(params.id)
@@ -327,7 +335,7 @@ export default function EmployeeDetailPage() {
<button
key={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}
</button>
@@ -428,12 +436,12 @@ export default function EmployeeDetailPage() {
</section>
{isEditing && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" />
Cancel
</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>
@@ -452,12 +460,12 @@ export default function EmployeeDetailPage() {
</section>
{isEditing && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" />
Cancel
</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>
@@ -485,7 +493,7 @@ export default function EmployeeDetailPage() {
</Field>
<label
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"
)}
title={uploadTypeId ? undefined : "Select a document type first"}
@@ -502,65 +510,105 @@ export default function EmployeeDetailPage() {
</label>
</div>
</section>
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.length === 0 ? (
<div className="rounded-2xl bg-card p-10 text-center text-muted-foreground shadow-sm ring-1 ring-foreground/10">No documents uploaded yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{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">
<div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
<p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
</div>
<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"
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
</div>
<div className="flex items-center justify-between border-t pt-3 text-sm">
<span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</div>
))}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</div>
)}
{tab === "Salary & Loans" && (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex 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>
<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>} />
@@ -644,37 +692,63 @@ export default function EmployeeDetailPage() {
</div>
)}
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 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.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{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>
<div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
<span className="text-muted-foreground">{s.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Effective to</p>
<p className="font-medium text-foreground">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</p>
</div>
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(s.basicSalary)}</p>
</div>
</div>
</div>
))}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</section>
<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>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
@@ -710,35 +784,61 @@ export default function EmployeeDetailPage() {
</DialogContent>
</Dialog>
</div>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 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.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{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>
<div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{l.docNo}</span>
<span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Principal</p>
<p className="font-medium text-foreground">{money(l.principalAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Outstanding</p>
<p className="font-medium text-foreground">{money(l.outstandingBalance)}</p>
</div>
</div>
</div>
))}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</section>
</div>
)}
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
</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">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
<div className="sticky bottom-0 -mx-4 flex flex-col-reverse gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:flex-row sm:justify-end sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel
</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" />
{submitting ? "Creating…" : "Create employee"}
</Button>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
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 { 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 { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
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 PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -40,6 +46,7 @@ export default function EmployeesPage() {
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
@@ -63,6 +70,12 @@ export default function EmployeesPage() {
})
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
@@ -261,159 +274,240 @@ export default function EmployeesPage() {
)}
{!error && employees !== null && employees.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{e.fullName}
</Link>
</TableCell>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{employees.map((e) => (
<div
key={e.employeeId}
role="link"
tabIndex={0}
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
onKeyDown={(ev) => {
if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{e.fullName}</p>
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{e.status}
</Badge>
</div>
<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">
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Department</p>
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Designation</p>
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Hire Date</p>
<p className="font-medium text-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
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">
{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>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${e.fullName}`}
title={`View ${e.fullName}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${e.fullName}`}
title={`Edit ${e.fullName}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</div>
))}
<TableCell className="px-3 py-4">
<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">
<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>
{pagination && employees.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
@@ -425,7 +519,7 @@ export default function EmployeesPage() {
<AlertDialogContent
variant="warning"
title="This employee has login access"
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login credentials? This cannot be undone — they will no longer be able to sign in, and the user will be removed from Settings > Users. Choose "Keep, but Deactivate" to only disable the login.`}
confirmLabel="Delete User Access"
cancelLabel="Keep, but Deactivate"
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
@@ -1,7 +1,7 @@
"use client"
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 { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -26,6 +26,9 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
const ALL_STATUS = "__all__"
@@ -48,6 +51,7 @@ export default function LeaveRequestsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
@@ -77,6 +81,12 @@ export default function LeaveRequestsPage() {
})
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -304,124 +314,155 @@ export default function LeaveRequestsPage() {
)}
{!error && requests !== null && requests.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{requests.map((r) => (
<div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
<span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Days</p>
<p className="font-medium text-foreground">{r.daysCount}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Dates</p>
<p className="font-medium text-foreground">
{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>
</p>
</div>
</div>
{r.status === "Submitted" && (
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-success hover:bg-success/10"
aria-label={`Approve ${r.docNo}`}
title={`Approve ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => approve(r.leaveRequestId)}
>
<CheckCircle2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Reject ${r.docNo}`}
title={`Reject ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => openReject(r)}
>
<XCircle className="size-4" />
</Button>
</div>
)}
</div>
))}
{pagination && requests.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
</div>
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex 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>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
</div>
<div className="text-right">
<div className="sm:text-right">
<p className="text-sm text-muted-foreground">Period</p>
<p className="font-semibold text-foreground">{period}</p>
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
@@ -1,9 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
import { useParams, useRouter } from "next/navigation"
import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
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 { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** Small tinted stat chip — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null)
@@ -136,11 +155,11 @@ export default function PayrollRunDetailPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
<StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
</div>
<div className="flex items-center gap-2.5">
@@ -177,114 +196,146 @@ export default function PayrollRunDetailPage() {
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View breakdown for ${l.employeeName}`}
title="View breakdown"
>
<Eye className="size-4" />
</Link>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<div
key={l.payrollLineId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{l.employeeName}</p>
<span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
</div>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
data-no-row-nav
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(l.basicSalary)}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Allowances</p>
<p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
</div>
<div>
<p className="text-muted-foreground">Deductions</p>
<p className="font-medium text-foreground">{money(totalDeductions)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Net Salary</p>
<p className="font-semibold text-foreground">{money(l.netSalary)}</p>
</div>
</div>
</div>
)
})}
{filteredLines.length > pagedLines.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
import { useRouter } from "next/navigation"
import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -10,17 +10,21 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
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 { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
const ALL_STATUS = "__all__"
@@ -31,12 +35,14 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
}
export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const now = new Date()
@@ -49,6 +55,12 @@ export default function PayrollRunsPage() {
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -141,102 +153,99 @@ export default function PayrollRunsPage() {
)}
{!error && runs !== null && runs.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${r.docNo}`}
title={`View ${r.docNo}`}
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{runs.map((r) => (
<div
key={r.payrollRunId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Employees</p>
<p className="font-medium text-foreground">{r.employeeCount}</p>
</div>
<div>
<p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
</div>
</div>
))}
{pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
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 { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null)
@@ -34,6 +37,7 @@ export default function DepartmentsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function DepartmentsPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -285,9 +295,9 @@ export default function DepartmentsPage() {
</Select>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -356,151 +366,209 @@ export default function DepartmentsPage() {
)}
{!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((d) => (
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((d) => (
<div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.name}</p>
<span className="font-mono text-xs text-muted-foreground">{d.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{d.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Parent</p>
<p className="font-medium text-foreground">
{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">
</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-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">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${d.name}`}
title={`Edit ${d.name}`}
onClick={() => openEdit(d)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
d.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)}
>
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<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}`}
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}
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>
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${d.name}?`}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
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 { errorMessage } from "@/lib/error-map"
@@ -24,6 +24,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null)
@@ -34,6 +37,7 @@ export default function DocumentTypesPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -59,6 +63,12 @@ export default function DocumentTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -261,9 +271,9 @@ export default function DocumentTypesPage() {
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -332,149 +342,205 @@ export default function DocumentTypesPage() {
)}
{!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Category</p>
<p className="font-medium text-foreground">{t.category}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{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">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<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}`}
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}
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>
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
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 { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -32,6 +35,7 @@ export default function LeaveTypesPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function LeaveTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -279,9 +289,9 @@ export default function LeaveTypesPage() {
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -351,151 +361,211 @@ export default function LeaveTypesPage() {
)}
{!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Days/yr</p>
<p className="font-medium text-foreground">{t.accrualPerYear}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{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">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<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}`}
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}
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>
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
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 { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -33,6 +36,7 @@ export default function SalaryComponentsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -57,6 +61,12 @@ export default function SalaryComponentsPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -254,9 +264,9 @@ export default function SalaryComponentsPage() {
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -325,149 +335,205 @@ export default function SalaryComponentsPage() {
)}
{!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((c) => (
<div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{c.name}</p>
<span className="font-mono text-xs text-muted-foreground">{c.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.componentType}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{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">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<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}`}
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}
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>
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -141,34 +141,66 @@ export default function StatutorySettingsPage() {
)}
{settings !== null && settings.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{settings.map((s) => (
<div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<p className="font-semibold text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">EPF (Employee)</p>
<p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">EPF (Employer)</p>
<p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">ETF (Employer)</p>
<p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">OT multiplier</p>
<p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
</div>
</div>
</div>
))}
</div>
</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>
@@ -225,28 +257,46 @@ export default function StatutorySettingsPage() {
)}
{slabs !== null && slabs.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slabs.map((s) => (
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{slabs.map((s) => (
<div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
<p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
</div>
<p className="border-t pt-3 text-sm text-muted-foreground">
{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
</p>
</div>
))}
</div>
</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>
</div>
@@ -1,7 +1,7 @@
"use client"
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 { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function maskToDays(mask: number): boolean[] {
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
@@ -37,6 +40,7 @@ export default function WorkShiftsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -75,6 +79,12 @@ export default function WorkShiftsPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -218,7 +228,7 @@ export default function WorkShiftsPage() {
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="sm:max-w-md">
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
@@ -287,7 +297,7 @@ export default function WorkShiftsPage() {
</div>
<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">
<DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
@@ -356,9 +366,9 @@ export default function WorkShiftsPage() {
</div>
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -427,149 +437,205 @@ export default function WorkShiftsPage() {
)}
{!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((w) => (
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{w.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((w) => (
<div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{w.name}</p>
<span className="font-mono text-xs text-muted-foreground">{w.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{w.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Hours</p>
<p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{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">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<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}`}
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}
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>
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
</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 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
)}
{!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)}`} />
<div className="mt-4">
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-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)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
)}
{!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 Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
)}
{!error && report !== null && (
<div className="rounded-2xl bg-white p-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
identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
@@ -97,36 +97,38 @@ export default function TrialBalancePage() {
)}
{!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)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
<div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</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>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
)}
</div>
@@ -0,0 +1,14 @@
import { Loader2 } from "lucide-react"
/**
* Shown inside the dashboard shell (sidebar and header stay put) while the next page's code
* loads — including programmatic navigations the top progress bar can't see start.
*/
export default function DashboardLoading() {
return (
<div role="status" aria-live="polite" className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Loading…</span>
</div>
)
}
+102 -37
View File
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign,
Boxes,
ClipboardList,
@@ -168,7 +170,7 @@ export default function DashboardPage() {
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<div className="h-64 sm:h-72">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(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>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div>
@@ -199,7 +201,7 @@ export default function DashboardPage() {
</div>
{loaded ? (
<div className="h-52 sm:h-64">
<div className="h-64 sm:h-72">
<LineChart
labels={movementTrend.labels}
datasets={[
@@ -209,7 +211,7 @@ export default function DashboardPage() {
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div>
</div>
@@ -227,48 +229,111 @@ export default function DashboardPage() {
{loaded ? (
movements.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
{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>
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
</div>
</div>
<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"
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{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>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
</div>
))}
</TableBody>
</Table>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex 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">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" />
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-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={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div>
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
</div>
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</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"}
</Button>
</div>
@@ -2,10 +2,12 @@
import { useEffect, useMemo, useState } from "react"
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 { branchesHrmApi } from "@/lib/api/hrm-masters"
import { dashboardApi } from "@/lib/api/dashboard"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
@@ -15,10 +17,12 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils"
@@ -33,6 +37,7 @@ const tabs: { value: StatusFilter; label: string }[] = [
]
export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
@@ -45,6 +50,15 @@ export default function PurchaseInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId })
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
.catch(() => setMonthlyPurchases([]))
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -140,6 +154,30 @@ export default function PurchaseInvoicesPage() {
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
</div>
{monthlyPurchases === null ? (
<Skeleton className="h-64 rounded-lg sm:h-72" />
) : monthlyPurchases.length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={monthlyPurchases.map((m) =>
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
)}
datasets={[
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
@@ -190,7 +228,99 @@ export default function PurchaseInvoicesPage() {
{!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">
<TableHeader>
<TableRow>
@@ -209,7 +339,14 @@ export default function PurchaseInvoicesPage() {
</TableHeader>
<TableBody>
{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">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo}
@@ -235,7 +372,7 @@ export default function PurchaseInvoicesPage() {
)}
</div>
</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">
{row.status === "Draft" && (
<Button
@@ -262,14 +399,6 @@ export default function PurchaseInvoicesPage() {
Pay
</Button>
)}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true)
try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
setPo(result.data)
setEtag(result.etag)
setLines(toDraftLines(result.data))
@@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
</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" && (
<>
<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" />
{submitting ? "Submitting…" : "Submit for approval"}
</Button>
<AlertDialog>
<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" />
{deleting ? "Deleting…" : "Delete draft"}
@@ -348,11 +348,11 @@ export default function PurchaseOrderDetailPage() {
)}
{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" />
{approving ? "Approving…" : "Approve"}
</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" />
Reject
</Button>
@@ -362,6 +362,7 @@ export default function PurchaseOrderDetailPage() {
<Button
variant="destructive"
size="lg"
className="w-full sm:w-auto"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
@@ -380,13 +381,13 @@ export default function PurchaseOrderDetailPage() {
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
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">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
<div className="flex flex-col-reverse gap-3 sm:flex-row">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back
</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"}
</Button>
</div>
@@ -437,7 +438,7 @@ export default function PurchaseOrderDetailPage() {
)}
<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>
{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 }])}>
@@ -447,7 +448,148 @@ export default function PurchaseOrderDetailPage() {
)}
</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">
<TableHeader>
<TableRow>
@@ -574,11 +716,11 @@ export default function PurchaseOrderDetailPage() {
</div>
{editable && (
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back
</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" />
{saving ? "Saving…" : "Save changes"}
</Button>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react"
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 { 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.
*/
export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => {
const vendor = vendorsById.get(po.vendorId)
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">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
@@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()}
</div>
</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">
<Button
type="button"
@@ -195,14 +204,6 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" />
Reject
</Button>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>

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