Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2b92f3086 |
@@ -1,92 +0,0 @@
|
|||||||
name: CI/CD - ERP Core Monorepo
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy-backend:
|
|
||||||
name: Build & Deploy Backend
|
|
||||||
runs-on: linux
|
|
||||||
if: github.event.pull_request.merged == true
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
run: |
|
|
||||||
git clone \
|
|
||||||
--depth 1 \
|
|
||||||
--branch production \
|
|
||||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
|
||||||
.
|
|
||||||
- name: Restore
|
|
||||||
run: dotnet restore Backend/ERPCore.sln
|
|
||||||
- name: Publish
|
|
||||||
run: |
|
|
||||||
rm -rf /tmp/erpcore-deploy
|
|
||||||
mkdir -p /tmp/erpcore-deploy
|
|
||||||
dotnet publish Backend/ERPCore/ERPCore.csproj \
|
|
||||||
-c Release \
|
|
||||||
-r linux-x64 \
|
|
||||||
--self-contained true \
|
|
||||||
-o /tmp/erpcore-deploy
|
|
||||||
- name: Stop service
|
|
||||||
run: systemctl stop erpcore.service
|
|
||||||
- name: Copy to deploy folder
|
|
||||||
run: |
|
|
||||||
mkdir -p /home/erp/core/publish
|
|
||||||
rm -rf /home/erp/core/publish/*
|
|
||||||
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
|
|
||||||
- name: Fix ownership and permissions
|
|
||||||
run: |
|
|
||||||
chown -R erpuser:erpuser /home/erp/core/publish
|
|
||||||
find /home/erp/core/publish -type d -exec chmod 755 {} +
|
|
||||||
find /home/erp/core/publish -type f -exec chmod 644 {} +
|
|
||||||
chmod +x /home/erp/core/publish/ERPCore
|
|
||||||
- name: Restart service
|
|
||||||
run: systemctl restart erpcore.service
|
|
||||||
- name: Verify
|
|
||||||
run: |
|
|
||||||
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
|
|
||||||
|
|
||||||
build-and-deploy-frontend:
|
|
||||||
name: Build & Deploy Frontend
|
|
||||||
runs-on: linux
|
|
||||||
if: github.event.pull_request.merged == true
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
run: |
|
|
||||||
git clone \
|
|
||||||
--depth 1 \
|
|
||||||
--branch production \
|
|
||||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
|
||||||
.
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
docker build --progress=plain --no-cache \
|
|
||||||
-t erpcore-frontend:latest \
|
|
||||||
./Frontend/erp-system
|
|
||||||
- name: Stop & remove existing container
|
|
||||||
run: |
|
|
||||||
docker stop erpcore-frontend 2>/dev/null || true
|
|
||||||
docker rm erpcore-frontend 2>/dev/null || true
|
|
||||||
- name: Start container
|
|
||||||
run: |
|
|
||||||
docker run -d \
|
|
||||||
--name erpcore-frontend \
|
|
||||||
-p 6702:6702 \
|
|
||||||
--add-host=host.docker.internal:host-gateway \
|
|
||||||
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--memory="512m" \
|
|
||||||
--cpus="1.0" \
|
|
||||||
--cap-drop ALL \
|
|
||||||
--security-opt no-new-privileges \
|
|
||||||
erpcore-frontend:latest
|
|
||||||
- name: Remove old images
|
|
||||||
run: docker image prune -f
|
|
||||||
- name: Verify
|
|
||||||
run: |
|
|
||||||
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
|
||||||
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
|
|
||||||
@@ -30,19 +30,6 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||||
|
|
||||||
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
|
|
||||||
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
|
|
||||||
[HttpGet("overview")]
|
|
||||||
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
|
|
||||||
[FromQuery] PageQuery query,
|
|
||||||
[FromQuery] EntityStatus? status,
|
|
||||||
[FromQuery] int? categoryId,
|
|
||||||
[FromQuery] int? subCategoryId,
|
|
||||||
[FromQuery] int? brandId,
|
|
||||||
CancellationToken ct)
|
|
||||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
|
|
||||||
|
|
||||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||||
[HttpGet("scan")]
|
[HttpGet("scan")]
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
[FromQuery] int? branchId,
|
[FromQuery] int? branchId,
|
||||||
[FromQuery] DateOnly? from,
|
|
||||||
[FromQuery] DateOnly? to,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ERPCore.Domain.Enums;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
@@ -19,9 +18,8 @@ public sealed class UomsController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
=> Ok(await _uoms.ListAsync(query, ct));
|
||||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||||
@@ -31,14 +29,4 @@ public sealed class UomsController : ApiControllerBase
|
|||||||
var dto = await _uoms.CreateAsync(request, ct);
|
var dto = await _uoms.CreateAsync(request, ct);
|
||||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
|
||||||
[HttpPatch("{uomId:int}/status")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -12,8 +10,4 @@ public class Uom
|
|||||||
{
|
{
|
||||||
public int UomId { get; set; }
|
public int UomId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ public sealed record DashboardStatsDto(
|
|||||||
int PendingCounts,
|
int PendingCounts,
|
||||||
int OpenRfqs,
|
int OpenRfqs,
|
||||||
int PendingLeaveRequests,
|
int PendingLeaveRequests,
|
||||||
int OverdueSalesInvoices,
|
int OverdueSalesInvoices);
|
||||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
|
||||||
|
|
||||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||||
|
|
||||||
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
|
|
||||||
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
|
|
||||||
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed record GrnDto(
|
|||||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||||
public sealed record GrnSummaryDto(
|
public sealed record GrnSummaryDto(
|
||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||||
|
|
||||||
public sealed record CreatedLayerDto(
|
public sealed record CreatedLayerDto(
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
namespace ERPCore.Dtos.Items;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
|
|
||||||
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
|
|
||||||
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
|
|
||||||
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
|
|
||||||
/// computed on every keystroke.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ItemOverviewDto(
|
|
||||||
int ItemId, string Sku, string Name,
|
|
||||||
int CategoryId, int? SubCategoryId, int? BrandId,
|
|
||||||
EntityStatus Status,
|
|
||||||
decimal StockQty, decimal StockValue, bool IsLowStock,
|
|
||||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
|
||||||
decimal SalesQtyLast30Days);
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
namespace ERPCore.Dtos.Uoms;
|
namespace ERPCore.Dtos.Uoms;
|
||||||
|
|
||||||
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
public sealed record UomDto(int UomId, string Name);
|
||||||
|
|
||||||
public sealed class CreateUomRequest
|
public sealed class CreateUomRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateUomStatusRequest
|
|
||||||
{
|
|
||||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
|
|||||||
|
|
||||||
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
||||||
builder.HasIndex(u => u.Name).IsUnique();
|
builder.HasIndex(u => u.Name).IsUnique();
|
||||||
|
|
||||||
builder.Property(u => u.Status)
|
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
|
||||||
.HasDefaultValue(EntityStatus.Active);
|
|
||||||
|
|
||||||
builder.Property(u => u.CreatedAt).IsRequired();
|
|
||||||
|
|
||||||
builder.HasIndex(u => u.Status);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-7954
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddUomLifecycleColumnsLocal : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
|
||||||
name: "CreatedAt",
|
|
||||||
table: "uoms",
|
|
||||||
type: "timestamp with time zone",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Status",
|
|
||||||
table: "uoms",
|
|
||||||
type: "character varying(20)",
|
|
||||||
maxLength: 20,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "Active");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
|
||||||
name: "UpdatedAt",
|
|
||||||
table: "uoms",
|
|
||||||
type: "timestamp with time zone",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_uoms_Status",
|
|
||||||
table: "uoms",
|
|
||||||
column: "Status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_uoms_Status",
|
|
||||||
table: "uoms");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "CreatedAt",
|
|
||||||
table: "uoms");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Status",
|
|
||||||
table: "uoms");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "UpdatedAt",
|
|
||||||
table: "uoms");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class changeMismatch : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6345,31 +6345,16 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)")
|
|
||||||
.HasDefaultValue("Active");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("UomId");
|
b.HasKey("UomId");
|
||||||
|
|
||||||
b.HasIndex("Name")
|
b.HasIndex("Name")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Status");
|
|
||||||
|
|
||||||
b.ToTable("uoms", (string)null);
|
b.ToTable("uoms", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "swagger",
|
||||||
"applicationUrl": "http://localhost:6701",
|
"applicationUrl": "http://localhost:5224",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "swagger",
|
||||||
"applicationUrl": "https://localhost:7112;http://localhost:6701",
|
"applicationUrl": "https://localhost:7112;http://localhost:5224",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ public sealed class DashboardService : IDashboardService
|
|||||||
private readonly IRepository<Rfq> _rfqs;
|
private readonly IRepository<Rfq> _rfqs;
|
||||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
|
||||||
private readonly IReorderService _reorder;
|
private readonly IReorderService _reorder;
|
||||||
|
|
||||||
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
||||||
@@ -34,14 +33,11 @@ public sealed class DashboardService : IDashboardService
|
|||||||
// balance older than this many days from its invoice date.
|
// balance older than this many days from its invoice date.
|
||||||
private const int OverdueAgeDays = 30;
|
private const int OverdueAgeDays = 30;
|
||||||
|
|
||||||
// Monthly Purchases chart window: the current month plus the previous 11.
|
|
||||||
private const int ChartMonths = 12;
|
|
||||||
|
|
||||||
public DashboardService(
|
public DashboardService(
|
||||||
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
||||||
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||||
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
|
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
|
||||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
IReorderService reorder)
|
||||||
{
|
{
|
||||||
_layers = layers;
|
_layers = layers;
|
||||||
_grns = grns;
|
_grns = grns;
|
||||||
@@ -51,7 +47,6 @@ public sealed class DashboardService : IDashboardService
|
|||||||
_rfqs = rfqs;
|
_rfqs = rfqs;
|
||||||
_leaveRequests = leaveRequests;
|
_leaveRequests = leaveRequests;
|
||||||
_salesInvoices = salesInvoices;
|
_salesInvoices = salesInvoices;
|
||||||
_purchaseInvoices = purchaseInvoices;
|
|
||||||
_reorder = reorder;
|
_reorder = reorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,50 +103,9 @@ public sealed class DashboardService : IDashboardService
|
|||||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||||
.Pagination.TotalItems;
|
.Pagination.TotalItems;
|
||||||
|
|
||||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
|
||||||
|
|
||||||
return new DashboardStatsDto(
|
return new DashboardStatsDto(
|
||||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
|
pendingLeaveRequests, overdueSalesInvoices);
|
||||||
}
|
|
||||||
|
|
||||||
/// <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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
|
|||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(g => new GrnSummaryDto(
|
.Select(g => new GrnSummaryDto(
|
||||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
||||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
|
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
|
||||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ public interface IItemService
|
|||||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||||
TrackingMode? trackingMode, CancellationToken ct = default);
|
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
|
|
||||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
|
||||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
|
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
|
|
||||||
public interface ISalesInvoiceService
|
public interface ISalesInvoiceService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
|
|
||||||
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
public interface IUomService
|
public interface IUomService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||||
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using ERPCore.Common.Http;
|
using ERPCore.Common.Http;
|
||||||
using ERPCore.Domain;
|
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Config;
|
using ERPCore.Dtos.Config;
|
||||||
@@ -36,7 +35,6 @@ public sealed class ItemService : IItemService
|
|||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<StockLayer> _stockLayers;
|
private readonly IRepository<StockLayer> _stockLayers;
|
||||||
private readonly IRepository<StockLedger> _stockLedger;
|
private readonly IRepository<StockLedger> _stockLedger;
|
||||||
private readonly IRepository<GrnLine> _grnLines;
|
|
||||||
private readonly IRepository<Batch> _batches;
|
private readonly IRepository<Batch> _batches;
|
||||||
private readonly IRepository<Serial> _serials;
|
private readonly IRepository<Serial> _serials;
|
||||||
private readonly IProductConfigService _config;
|
private readonly IProductConfigService _config;
|
||||||
@@ -54,7 +52,6 @@ public sealed class ItemService : IItemService
|
|||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
IRepository<StockLayer> stockLayers,
|
IRepository<StockLayer> stockLayers,
|
||||||
IRepository<StockLedger> stockLedger,
|
IRepository<StockLedger> stockLedger,
|
||||||
IRepository<GrnLine> grnLines,
|
|
||||||
IRepository<Batch> batches,
|
IRepository<Batch> batches,
|
||||||
IRepository<Serial> serials,
|
IRepository<Serial> serials,
|
||||||
IProductConfigService config,
|
IProductConfigService config,
|
||||||
@@ -71,7 +68,6 @@ public sealed class ItemService : IItemService
|
|||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_stockLayers = stockLayers;
|
_stockLayers = stockLayers;
|
||||||
_stockLedger = stockLedger;
|
_stockLedger = stockLedger;
|
||||||
_grnLines = grnLines;
|
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
_serials = serials;
|
_serials = serials;
|
||||||
_config = config;
|
_config = config;
|
||||||
@@ -112,82 +108,6 @@ public sealed class ItemService : IItemService
|
|||||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var q = _items.Query().AsNoTracking();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
|
||||||
{
|
|
||||||
var term = query.Q.Trim();
|
|
||||||
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
|
|
||||||
}
|
|
||||||
if (status is not null) q = q.Where(i => i.Status == status);
|
|
||||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
|
||||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
|
||||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var page = await q.OrderBy(i => i.Sku)
|
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
|
||||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
|
||||||
|
|
||||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
|
||||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
|
||||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
|
||||||
.GroupBy(l => l.ItemId)
|
|
||||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
|
||||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
|
||||||
|
|
||||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
|
||||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
|
||||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
|
||||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
|
||||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
|
||||||
.ToListAsync(ct))
|
|
||||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
|
||||||
|
|
||||||
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
|
|
||||||
// reduced in memory rather than a "first per group" query EF can't always translate.
|
|
||||||
var grnCandidates = await _grnLines.Query().AsNoTracking()
|
|
||||||
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
|
|
||||||
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var lastPurchaseByItem = grnCandidates
|
|
||||||
.GroupBy(x => x.ItemId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
|
|
||||||
|
|
||||||
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
|
|
||||||
// read off the stock ledger — the one place every sale channel posts uniformly.
|
|
||||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
|
||||||
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
|
|
||||||
var salesByItem = await _stockLedger.Query().AsNoTracking()
|
|
||||||
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
|
|
||||||
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
|
|
||||||
.GroupBy(l => l.ItemId)
|
|
||||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
|
|
||||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
|
||||||
|
|
||||||
var rows = page.Select(i =>
|
|
||||||
{
|
|
||||||
var stock = stockByItem.GetValueOrDefault(i.ItemId);
|
|
||||||
var isLowStock = i.ReorderSettings.Any(rs =>
|
|
||||||
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
|
|
||||||
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
|
|
||||||
|
|
||||||
return new ItemOverviewDto(
|
|
||||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
|
|
||||||
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
|
|
||||||
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
|
|
||||||
salesByItem.GetValueOrDefault(i.ItemId));
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -64,9 +64,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
||||||
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
||||||
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
|
||||||
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
|
||||||
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _uoms.Query().AsNoTracking();
|
var q = _uoms.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
||||||
}
|
}
|
||||||
if (status is not null) q = q.Where(u => u.Status == status);
|
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(u => u.Name)
|
var rows = await q.OrderBy(u => u.Name)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
|
.Select(u => new UomDto(u.UomId, u.Name))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
|
|||||||
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
||||||
throw new ConflictException($"A UOM named '{name}' already exists.");
|
throw new ConflictException($"A UOM named '{name}' already exists.");
|
||||||
|
|
||||||
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
var uom = new Uom { Name = name };
|
||||||
await _uoms.AddAsync(uom, ct);
|
await _uoms.AddAsync(uom, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
|
return new UomDto(uom.UomId, uom.Name);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var uom = await _uoms.GetByIdAsync(uomId, ct)
|
|
||||||
?? throw new NotFoundException($"UOM {uomId} was not found.");
|
|
||||||
|
|
||||||
uom.Status = status;
|
|
||||||
uom.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:5011"
|
"BaseUrl": "http://localhost:5011"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": ""
|
||||||
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"SigningKey": ""
|
"SigningKey": ""
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:6703"
|
"BaseUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
|
||||||
},
|
},
|
||||||
"Urls": "http://0.0.0.0:6701",
|
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Issuer": "AuthHex",
|
"Issuer": "AuthHex",
|
||||||
"Audience": "AuthHexClient",
|
"Audience": "AuthHexClient",
|
||||||
@@ -17,7 +16,7 @@
|
|||||||
"RequiredRoleCode": ""
|
"RequiredRoleCode": ""
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:6703"
|
"BaseUrl": "http://localhost:5602"
|
||||||
},
|
},
|
||||||
"FileStorage": {
|
"FileStorage": {
|
||||||
"RootPath": "App_Data/hr-documents",
|
"RootPath": "App_Data/hr-documents",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
build
|
|
||||||
coverage
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.local.example
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
@@ -4,4 +4,4 @@
|
|||||||
#
|
#
|
||||||
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
|
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
|
||||||
# proxy will refuse.
|
# proxy will refuse.
|
||||||
BACKEND_ORIGIN=http://localhost:6701
|
BACKEND_ORIGIN=http://localhost:5224
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
FROM node:22-alpine AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV PORT=6702
|
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
RUN npm prune --production && npm cache clean --force
|
|
||||||
|
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
|
||||||
|
|
||||||
EXPOSE 6702
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
|
|||||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||||
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
|
|||||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||||
>
|
>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
{b.startChequeNo} – {b.endChequeNo}
|
{b.startChequeNo} – {b.endChequeNo}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||||
{b.status}
|
{b.status}
|
||||||
|
|||||||
@@ -146,12 +146,12 @@ export default function ReceivedChequesPage() {
|
|||||||
>
|
>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||||
{c.status}
|
{c.status}
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
|
|
||||||
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Customer, CustomerType } from "@/types/customers"
|
|
||||||
import { SalesInvoiceSummary } from "@/types/sales"
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { FieldError } from "@/components/ui/field"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
|
||||||
|
|
||||||
const INVOICE_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
|
|
||||||
/** Small tinted stat chip for the profile header — value + label + icon. */
|
|
||||||
function StatChip({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Icon className="size-4.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CustomerDetailPage() {
|
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
const customerId = Number(params.id)
|
|
||||||
|
|
||||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
|
||||||
const [etag, setEtag] = useState<string | null>(null)
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [customerCode, setCustomerCode] = useState("")
|
|
||||||
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [displayName, setDisplayName] = useState("")
|
|
||||||
const [phone, setPhone] = useState("")
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [addressLine1, setAddressLine1] = useState("")
|
|
||||||
const [addressLine2, setAddressLine2] = useState("")
|
|
||||||
const [city, setCity] = useState("")
|
|
||||||
const [country, setCountry] = useState("")
|
|
||||||
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
|
|
||||||
const [creditLimit, setCreditLimit] = useState("0")
|
|
||||||
const [creditDays, setCreditDays] = useState("0")
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [conflict, setConflict] = useState(false)
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
|
|
||||||
// Profile activity: this customer's recent sales invoices ("selling").
|
|
||||||
const [invoices, setInvoices] = useState<SalesInvoiceSummary[] | null>(null)
|
|
||||||
|
|
||||||
function applyCustomer(data: Customer) {
|
|
||||||
setCustomer(data)
|
|
||||||
setCustomerCode(data.customerCode)
|
|
||||||
setCustomerType(data.customerType)
|
|
||||||
setName(data.name)
|
|
||||||
setDisplayName(data.displayName ?? "")
|
|
||||||
setPhone(data.phone ?? "")
|
|
||||||
setEmail(data.email ?? "")
|
|
||||||
setAddressLine1(data.addressLine1 ?? "")
|
|
||||||
setAddressLine2(data.addressLine2 ?? "")
|
|
||||||
setCity(data.city ?? "")
|
|
||||||
setCountry(data.country ?? "")
|
|
||||||
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
|
|
||||||
setCreditLimit(String(data.creditLimit))
|
|
||||||
setCreditDays(String(data.creditDays))
|
|
||||||
}
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
setLoadError(null)
|
|
||||||
customersApi
|
|
||||||
.get(customerId)
|
|
||||||
.then(({ data, etag: tag }) => {
|
|
||||||
applyCustomer(data)
|
|
||||||
setEtag(tag)
|
|
||||||
setConflict(false)
|
|
||||||
})
|
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Number.isFinite(customerId)) load()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
/** Opens the edit dialog fresh from the last-loaded customer, discarding any unsaved
|
|
||||||
* edits left over from a previous cancelled session. */
|
|
||||||
function openEdit() {
|
|
||||||
if (customer) applyCustomer(customer)
|
|
||||||
setErrors({})
|
|
||||||
setSaveError(null)
|
|
||||||
setEditOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEdit() {
|
|
||||||
if (customer) applyCustomer(customer)
|
|
||||||
setEditOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Number.isFinite(customerId)) return
|
|
||||||
salesApi.listInvoices({ customerId, pageSize: 100 }).then((res) => setInvoices(res.items)).catch(() => setInvoices([]))
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices])
|
|
||||||
const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices])
|
|
||||||
const recentInvoices = useMemo(
|
|
||||||
() => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()),
|
|
||||||
[invoices]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [invoicePage, setInvoicePage] = useState(1)
|
|
||||||
const [invoicePageSize, setInvoicePageSize] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
|
|
||||||
const [mobileInvoiceCount, setMobileInvoiceCount] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
|
|
||||||
const invoicePagination = {
|
|
||||||
page: invoicePage,
|
|
||||||
pageSize: invoicePageSize,
|
|
||||||
totalItems: recentInvoices.length,
|
|
||||||
totalPages: Math.max(1, Math.ceil(recentInvoices.length / invoicePageSize)),
|
|
||||||
}
|
|
||||||
const pagedInvoices = recentInvoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize)
|
|
||||||
const monthlySales = useMemo(() => {
|
|
||||||
const byMonth = new Map<string, number>()
|
|
||||||
for (const inv of invoices ?? []) {
|
|
||||||
const key = inv.invoiceDate.slice(0, 7)
|
|
||||||
byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal)
|
|
||||||
}
|
|
||||||
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
|
|
||||||
}, [invoices])
|
|
||||||
|
|
||||||
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
|
|
||||||
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(max-width: 639px)")
|
|
||||||
setIsMobile(mql.matches)
|
|
||||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!customer || !etag) return
|
|
||||||
setSaveError(null)
|
|
||||||
const nextErrors: Record<string, string> = {}
|
|
||||||
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
|
|
||||||
if (!name.trim()) nextErrors.name = "Customer name is required"
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const result = await customersApi.update(
|
|
||||||
customerId,
|
|
||||||
{
|
|
||||||
customerCode,
|
|
||||||
customerType,
|
|
||||||
name,
|
|
||||||
displayName: displayName || null,
|
|
||||||
phone: phone || null,
|
|
||||||
email: email || null,
|
|
||||||
addressLine1: addressLine1 || null,
|
|
||||||
addressLine2: addressLine2 || null,
|
|
||||||
city: city || null,
|
|
||||||
country: country || null,
|
|
||||||
taxRegistrationNo: taxRegistrationNo || null,
|
|
||||||
creditLimit: Number(creditLimit) || 0,
|
|
||||||
creditDays: Number(creditDays) || 0,
|
|
||||||
defaultWarehouseId: customer.defaultWarehouseId,
|
|
||||||
branchId: customer.branchId,
|
|
||||||
},
|
|
||||||
etag
|
|
||||||
)
|
|
||||||
applyCustomer(result.data)
|
|
||||||
setEtag(result.etag)
|
|
||||||
setEditOpen(false)
|
|
||||||
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
|
|
||||||
} catch (err) {
|
|
||||||
const code = (err as { code?: string })?.code
|
|
||||||
if (code === "CONCURRENCY_CONFLICT") {
|
|
||||||
setConflict(true)
|
|
||||||
setSaveError(errorMessage(err))
|
|
||||||
setSaving(false)
|
|
||||||
setEditOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fe = fieldErrors(err)
|
|
||||||
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
|
|
||||||
setSaveError(errorMessage(err))
|
|
||||||
toast.error("Could not save customer", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleStatus() {
|
|
||||||
if (!customer) return
|
|
||||||
const next = customer.status === "Active" ? "Inactive" : "Active"
|
|
||||||
setTogglingStatus(true)
|
|
||||||
try {
|
|
||||||
await customersApi.updateStatus(customerId, next)
|
|
||||||
toast.success(next === "Active" ? "Customer activated" : "Customer deactivated")
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not update status", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setTogglingStatus(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadError && !customer) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
|
||||||
<Link href="/dashboard/customers" className={cn(buttonVariants({ variant: "outline" }))}>
|
|
||||||
Back to customers
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Skeleton className="h-10 w-72" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/customers"
|
|
||||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Back to Customers
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
|
||||||
<Users className="size-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">{customer.customerCode}</h1>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
|
||||||
customer.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{customer.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-base text-muted-foreground">{customer.displayName ?? customer.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant={customer.status === "Active" ? "destructive" : "success"}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={handleToggleStatus}
|
|
||||||
disabled={togglingStatus}
|
|
||||||
>
|
|
||||||
{togglingStatus ? "Updating…" : customer.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
||||||
<StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
|
|
||||||
<StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} />
|
|
||||||
<StatChip
|
|
||||||
icon={AlertTriangle}
|
|
||||||
label="Outstanding balance"
|
|
||||||
value={invoices === null ? "…" : `LKR ${outstandingBalance.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{conflict && (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
|
||||||
<AlertTriangle className="size-5 shrink-0" />
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p>{saveError ?? "This customer was changed by someone else."} Reload before retrying.</p>
|
|
||||||
<Button size="sm" variant="outline" onClick={load}>
|
|
||||||
Reload
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{saveError && !conflict && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Monthly sales</h2>
|
|
||||||
{invoices === null ? (
|
|
||||||
<Skeleton className="h-85 w-full" />
|
|
||||||
) : monthlySales.length > 0 ? (
|
|
||||||
<BarChart
|
|
||||||
// Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
|
|
||||||
// activity, Chart.js would otherwise center the lone category across the full
|
|
||||||
// width. Trailing blank slots keep the axis/gridlines spanning the full card
|
|
||||||
// while the real bars sit at the start. Skipped on mobile — there the bars
|
|
||||||
// should stretch to fill the narrower card instead.
|
|
||||||
labels={[
|
|
||||||
...monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
|
|
||||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill("")),
|
|
||||||
]}
|
|
||||||
datasets={[
|
|
||||||
{
|
|
||||||
label: "Sold (LKR)",
|
|
||||||
data: [
|
|
||||||
...monthlySales.map(([, v]) => v),
|
|
||||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill(null)),
|
|
||||||
],
|
|
||||||
backgroundColor: "rgba(239,68,68,0.6)",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
barPercentage={0.6}
|
|
||||||
categoryPercentage={0.6}
|
|
||||||
height={340}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2>
|
|
||||||
{invoices === null ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border">
|
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : recentInvoices.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
|
|
||||||
<div className="flex flex-col gap-2 md:hidden">
|
|
||||||
{recentInvoices.slice(0, mobileInvoiceCount).map((inv) => (
|
|
||||||
<Link
|
|
||||||
key={inv.salesInvoiceId}
|
|
||||||
href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`}
|
|
||||||
className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm hover:bg-muted/20"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate font-semibold text-foreground">{inv.invoiceNo}</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
|
||||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-muted px-2 text-xs text-muted-foreground">
|
|
||||||
{inv.status}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-medium text-foreground">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{mobileInvoiceCount < recentInvoices.length && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => setMobileInvoiceCount((c) => c + INVOICE_PAGE_SIZE_OPTIONS[0])}
|
|
||||||
>
|
|
||||||
Show more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Invoice</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Total</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pagedInvoices.map((inv) => (
|
|
||||||
<TableRow key={inv.salesInvoiceId} className="align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-semibold text-foreground hover:underline">
|
|
||||||
{inv.invoiceNo}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
|
|
||||||
{inv.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">
|
|
||||||
LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={invoicePagination}
|
|
||||||
onPageChange={setInvoicePage}
|
|
||||||
pageSizeOptions={INVOICE_PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setInvoicePage(1); setInvoicePageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit {customer.customerCode}</DialogTitle>
|
|
||||||
<DialogDescription>Update this customer's details.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Code</Label>
|
|
||||||
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} />
|
|
||||||
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Type</Label>
|
|
||||||
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="B2C" className="text-base">B2C — individual</SelectItem>
|
|
||||||
<SelectItem value="B2B" className="text-base">B2B — business</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Name</Label>
|
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Display name (optional)</Label>
|
|
||||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Phone</Label>
|
|
||||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Email</Label>
|
|
||||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Address line 1</Label>
|
|
||||||
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Address line 2</Label>
|
|
||||||
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">City</Label>
|
|
||||||
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Country</Label>
|
|
||||||
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Tax registration</Label>
|
|
||||||
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Credit limit (LKR)</Label>
|
|
||||||
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Credit days</Label>
|
|
||||||
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
|
||||||
<Save className="size-5" />
|
|
||||||
{saving ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
|
|
||||||
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
|
||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { generateCustomerCode } from "@/lib/customer-code"
|
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
|
||||||
import { Customer, CustomerType } from "@/types/customers"
|
|
||||||
import { Branch } from "@/types/hrm"
|
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
|
||||||
type TypeFilter = CustomerType | "All"
|
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function CustomersPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
|
||||||
const [customers, setCustomers] = useState<Customer[] | null>(null)
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
|
||||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
|
||||||
const [customerType, setCustomerType] = useState<TypeFilter>("All")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
|
|
||||||
const [phone, setPhone] = useState("")
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [creditLimit, setCreditLimit] = useState("")
|
|
||||||
const [creditDays, setCreditDays] = useState("")
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
// Every other customer's code, to de-dupe against when the name edit regenerates this
|
|
||||||
// customer's own code (mirrors the vendor create dialog).
|
|
||||||
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
|
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [searchInput])
|
|
||||||
|
|
||||||
useEffect(() => setPage(1), [query, status, customerType])
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
setError(null)
|
|
||||||
customersApi
|
|
||||||
.list({
|
|
||||||
q: query || undefined,
|
|
||||||
status: status === "All" ? undefined : status,
|
|
||||||
customerType: customerType === "All" ? undefined : customerType,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
setCustomers(res.items)
|
|
||||||
setPagination(res.pagination)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [query, status, customerType, page, pageSize])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
|
||||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
|
||||||
useEffect(() => {
|
|
||||||
if (branchingEnabled && isAdmin) {
|
|
||||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
|
||||||
}
|
|
||||||
}, [branchingEnabled, isAdmin])
|
|
||||||
|
|
||||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
|
||||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
|
||||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
setName("")
|
|
||||||
setNewCustomerType("B2C")
|
|
||||||
setPhone("")
|
|
||||||
setEmail("")
|
|
||||||
setCreditLimit("")
|
|
||||||
setCreditDays("")
|
|
||||||
setBranchFilterId(null)
|
|
||||||
setErrors({})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const nextErrors: Record<string, string> = {}
|
|
||||||
if (!name.trim()) nextErrors.name = "Customer name is required"
|
|
||||||
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const result = await customersApi.create({
|
|
||||||
customerCode: generatedCode,
|
|
||||||
customerType: newCustomerType,
|
|
||||||
name,
|
|
||||||
phone: phone || null,
|
|
||||||
email: email || null,
|
|
||||||
creditLimit: Number(creditLimit) || 0,
|
|
||||||
creditDays: Number(creditDays) || 0,
|
|
||||||
branchId: effectiveBranchId ?? 0,
|
|
||||||
})
|
|
||||||
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
|
|
||||||
setOpen(false)
|
|
||||||
resetForm()
|
|
||||||
load()
|
|
||||||
setAllCustomerCodes((codes) => [...codes, result.data.customerCode])
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not create customer", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI maps to
|
|
||||||
* PATCH status → Inactive, same mechanism as the profile page's toggle. */
|
|
||||||
async function handleDeactivate(customer: Customer) {
|
|
||||||
setActionPendingId(customer.customerId)
|
|
||||||
try {
|
|
||||||
await customersApi.updateStatus(customer.customerId, "Inactive")
|
|
||||||
toast.success("Customer deleted", `${customer.customerCode} has been deactivated.`)
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not delete customer", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setActionPendingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Customers</h1>
|
|
||||||
<p className="text-base text-muted-foreground">Customer master data used by sales invoices, slips and bundles.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger
|
|
||||||
render={
|
|
||||||
<Button size="lg" onClick={resetForm}>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
New Customer
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>New customer</DialogTitle>
|
|
||||||
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
|
||||||
<FieldLabel htmlFor="c-name">Name</FieldLabel>
|
|
||||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
|
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-code">Code (auto-generated)</FieldLabel>
|
|
||||||
<Input id="c-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-type">Customer type</FieldLabel>
|
|
||||||
<Select<CustomerType> value={newCustomerType} onValueChange={(v) => v && setNewCustomerType(v)}>
|
|
||||||
<SelectTrigger id="c-type" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="B2C">B2C — individual</SelectItem>
|
|
||||||
<SelectItem value="B2B">B2B — business</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
|
||||||
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
|
||||||
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
|
||||||
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
|
|
||||||
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
{showBranchPicker && (
|
|
||||||
<Field data-invalid={!!errors.branch}>
|
|
||||||
<FieldLabel htmlFor="c-branch">Branch</FieldLabel>
|
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
|
||||||
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
|
|
||||||
<SelectValue placeholder="Select branch" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative flex-1 basis-0">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
placeholder="Search code or name…"
|
|
||||||
className="h-14 w-full pl-11 text-base"
|
|
||||||
aria-label="Search customers"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select<TypeFilter> value={customerType} onValueChange={(v) => setCustomerType(v ?? "All")}>
|
|
||||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="All" className="text-base">All types</SelectItem>
|
|
||||||
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
|
|
||||||
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
|
||||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
|
||||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
|
||||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && customers === null && (
|
|
||||||
<div className="overflow-hidden rounded-xl border">
|
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="ml-4 h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && customers !== null && customers.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
|
||||||
<Users className="size-12 text-muted-foreground" />
|
|
||||||
<p className="text-base text-muted-foreground">
|
|
||||||
{hasFilters ? "No customers match your search/filter." : "No customers yet."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && customers !== null && customers.length > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Mobile: stacked cards. */}
|
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{customers.map((c) => (
|
|
||||||
<div
|
|
||||||
key={c.customerId}
|
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/customers/${c.customerId}`)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold text-foreground">{c.customerCode}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{c.displayName ?? c.name}</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
|
||||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Type</p>
|
|
||||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Phone</p>
|
|
||||||
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Credit limit</p>
|
|
||||||
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/customers/${c.customerId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Edit ${c.customerCode}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
aria-label={`Delete ${c.customerCode}`}
|
|
||||||
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${c.customerCode}?`}
|
|
||||||
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDeactivate(c)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && customers.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-32">Code</TableHead>
|
|
||||||
<TableHead className="w-48">Name</TableHead>
|
|
||||||
<TableHead className="w-28">Type</TableHead>
|
|
||||||
<TableHead className="w-36">Phone</TableHead>
|
|
||||||
<TableHead className="w-36">Credit limit</TableHead>
|
|
||||||
<TableHead className="w-28">Status</TableHead>
|
|
||||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<TableRow
|
|
||||||
key={c.customerId}
|
|
||||||
className="align-top cursor-pointer hover:bg-muted/20"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/customers/${c.customerId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
|
|
||||||
{c.customerCode}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{c.displayName ?? c.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{c.customerType}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
|
||||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell data-no-row-nav>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/customers/${c.customerId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View ${c.customerCode}`}
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/customers/${c.customerId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Edit ${c.customerCode}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
aria-label={`Delete ${c.customerCode}`}
|
|
||||||
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${c.customerCode}?`}
|
|
||||||
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDeactivate(c)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={pagination}
|
|
||||||
onPageChange={setPage}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -31,16 +33,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size
|
|
||||||
// client-side, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
Open: "border-transparent bg-primary/10 text-primary",
|
Open: "border-transparent bg-primary/10 text-primary",
|
||||||
@@ -290,104 +288,98 @@ export default function HelpPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
|
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{pageItems.map((t) => (
|
<TableHeader>
|
||||||
<Link
|
<TableRow className="hover:bg-transparent">
|
||||||
key={t.ticketId}
|
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
|
||||||
href={`/dashboard/help/tickets/${t.ticketId}`}
|
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
|
||||||
>
|
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
|
||||||
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
|
</TableRow>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
|
</TableHeader>
|
||||||
{statusLabels[t.status]}
|
<TableBody>
|
||||||
</Badge>
|
{pageItems.map((t) => (
|
||||||
</div>
|
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-3 py-4">
|
||||||
{isAdmin && (
|
<Link
|
||||||
<div>
|
href={`/dashboard/help/tickets/${t.ticketId}`}
|
||||||
<p className="text-muted-foreground">Submitted By</p>
|
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
<p className="font-medium text-foreground">{t.createdByName}</p>
|
>
|
||||||
</div>
|
{t.subject}
|
||||||
)}
|
</Link>
|
||||||
<div>
|
</TableCell>
|
||||||
<p className="text-muted-foreground">Messages</p>
|
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
|
||||||
<p className="font-medium text-foreground">{t.messageCount}</p>
|
<TableCell className="px-3 py-4">
|
||||||
</div>
|
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
|
||||||
<div>
|
{statusLabels[t.status]}
|
||||||
<p className="text-muted-foreground">Last Updated</p>
|
</Badge>
|
||||||
<p className="font-medium text-foreground">
|
</TableCell>
|
||||||
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
|
||||||
</p>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
</div>
|
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
|
||||||
</div>
|
year: "numeric",
|
||||||
</Link>
|
month: "short",
|
||||||
))}
|
day: "numeric",
|
||||||
|
})}
|
||||||
{totalItems > pageItems.length && (
|
</TableCell>
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
|
|
||||||
Show more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
|
|
||||||
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
|
|
||||||
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
|
|
||||||
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{pageItems.map((t) => (
|
</Table>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalItems > 0 && (
|
{totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={{ page, pageSize, totalItems, totalPages }}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
of <span className="font-medium text-foreground">{totalItems}</span> tickets
|
||||||
/>
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
|
|||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
|
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
|
||||||
<SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Open">Open</SelectItem>
|
<SelectItem value="Open">Open</SelectItem>
|
||||||
<SelectItem value="InProgress">In Progress</SelectItem>
|
<SelectItem value="InProgress">In Progress</SelectItem>
|
||||||
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
|
|||||||
{ticket.messages.map((m) => (
|
{ticket.messages.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.messageId}
|
key={m.messageId}
|
||||||
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
|
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
|
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
|
|||||||
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
|
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
|
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
|
||||||
{sending ? "Sending…" : "Send"}
|
{sending ? "Sending…" : "Send"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
|
||||||
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
|
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
|
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
|
||||||
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
|
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
|
||||||
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
|
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
|
||||||
@@ -148,95 +148,47 @@ export default function AttendanceBatchDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile: stacked cards. */}
|
<Table className="text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
{records.map((r) => (
|
<TableRow>
|
||||||
<div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
|
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
|
||||||
<p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
|
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
|
||||||
</div>
|
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
</div>
|
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
|
||||||
<div>
|
</TableRow>
|
||||||
<p className="text-muted-foreground">In / Out</p>
|
</TableHeader>
|
||||||
<p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
|
<TableBody>
|
||||||
</div>
|
{records.map((r) => (
|
||||||
<div>
|
<TableRow key={r.attendanceRecordId}>
|
||||||
<p className="text-muted-foreground">Working Hours</p>
|
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
|
||||||
<p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
|
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
|
||||||
<p className="text-muted-foreground">Late</p>
|
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
|
||||||
<p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
|
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="text-muted-foreground">OT</p>
|
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
|
||||||
<p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
|
</TableCell>
|
||||||
</div>
|
{!locked && (
|
||||||
<div className="col-span-2">
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="text-muted-foreground">Row status</p>
|
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
|
||||||
<p className="font-medium text-foreground">{r.attendanceStatus}</p>
|
<div className="flex gap-1">
|
||||||
</div>
|
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
|
||||||
</div>
|
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
|
||||||
{!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableRow>
|
||||||
</Table>
|
))}
|
||||||
</div>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import Link from "next/link"
|
||||||
import { Download, Plus, Upload } from "lucide-react"
|
import { Download, Eye, Plus, Upload } from "lucide-react"
|
||||||
|
|
||||||
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
|
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -15,15 +15,11 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 10
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 25
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
||||||
Draft: "bg-muted text-muted-foreground",
|
Draft: "bg-muted text-muted-foreground",
|
||||||
@@ -33,13 +29,10 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AttendanceBatchesPage() {
|
export default function AttendanceBatchesPage() {
|
||||||
const router = useRouter()
|
|
||||||
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
|
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [periodStart, setPeriodStart] = useState("")
|
const [periodStart, setPeriodStart] = useState("")
|
||||||
@@ -49,17 +42,11 @@ export default function AttendanceBatchesPage() {
|
|||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
attendanceApi
|
attendanceApi
|
||||||
.list({ page, pageSize })
|
.list({ page, pageSize: PAGE_SIZE })
|
||||||
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
|
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
}
|
||||||
|
useEffect(load, [page])
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
useEffect(load, [page, pageSize])
|
|
||||||
|
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (!file) { toast.error("Choose an Excel or CSV file first"); return }
|
if (!file) { toast.error("Choose an Excel or CSV file first"); return }
|
||||||
@@ -87,12 +74,12 @@ export default function AttendanceBatchesPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
|
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
|
||||||
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.</p>
|
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex gap-2">
|
||||||
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
|
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
<Download className="size-4" />Download template
|
<Download className="size-4" />Download template
|
||||||
</a>
|
</a>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
|
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>Upload Attendance</DialogTitle>
|
<DialogTitle>Upload Attendance</DialogTitle>
|
||||||
@@ -123,83 +110,41 @@ export default function AttendanceBatchesPage() {
|
|||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
|
||||||
)}
|
)}
|
||||||
{!error && batches !== null && batches.length > 0 && (
|
{!error && batches !== null && batches.length > 0 && (
|
||||||
<>
|
<Table className="text-base">
|
||||||
{/* Mobile: stacked cards. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{batches.map((b) => (
|
{batches.map((b) => (
|
||||||
<div
|
<TableRow key={b.attendanceUploadBatchId}>
|
||||||
key={b.attendanceUploadBatchId}
|
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
|
||||||
role="link"
|
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
|
||||||
tabIndex={0}
|
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
|
||||||
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
|
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
|
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
<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>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</TableCell>
|
||||||
<div className="min-w-0">
|
</TableRow>
|
||||||
<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>
|
||||||
{pagination && batches.length < pagination.totalItems && (
|
</Table>
|
||||||
<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 && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center justify-between">
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useSearchParams } from "next/navigation"
|
import { useParams, useSearchParams } from "next/navigation"
|
||||||
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
|
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
|
||||||
|
|
||||||
@@ -44,14 +44,6 @@ function money(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmployeeDetailPage() {
|
export default function EmployeeDetailPage() {
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<EmployeeDetailPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmployeeDetailPageContent() {
|
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const employeeId = Number(params.id)
|
const employeeId = Number(params.id)
|
||||||
@@ -335,7 +327,7 @@ function EmployeeDetailPageContent() {
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={cn("shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors sm:flex-1", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
</button>
|
</button>
|
||||||
@@ -436,12 +428,12 @@ function EmployeeDetailPageContent() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
|
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,12 +452,12 @@ function EmployeeDetailPageContent() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
|
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -493,7 +485,7 @@ function EmployeeDetailPageContent() {
|
|||||||
</Field>
|
</Field>
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-black px-4 text-sm font-medium sm:w-fit",
|
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
|
||||||
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
|
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
|
||||||
)}
|
)}
|
||||||
title={uploadTypeId ? undefined : "Select a document type first"}
|
title={uploadTypeId ? undefined : "Select a document type first"}
|
||||||
@@ -510,105 +502,65 @@ function EmployeeDetailPageContent() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{documents.length === 0 ? (
|
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||||
<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>
|
<div className="overflow-x-auto">
|
||||||
) : (
|
<Table className="text-base">
|
||||||
<>
|
<TableHeader>
|
||||||
{/* Mobile: stacked cards. */}
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<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) => (
|
{documents.map((d) => (
|
||||||
<div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
||||||
<div className="min-w-0">
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||||
<p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||||
<p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
|
<TableCell className="px-4 py-3.5">
|
||||||
</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{d.status}
|
{d.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</TableCell>
|
||||||
<div className="flex items-center justify-between border-t pt-3 text-sm">
|
<TableCell className="px-4 py-3.5">
|
||||||
<span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
|
<div className="flex justify-center">
|
||||||
<a
|
<a
|
||||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label={`Download ${d.originalFileName}`}
|
aria-label={`Download ${d.originalFileName}`}
|
||||||
title={`Download ${d.originalFileName}`}
|
title={`Download ${d.originalFileName}`}
|
||||||
>
|
>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</div>
|
{documents.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
|
||||||
{/* Desktop: table. */}
|
)}
|
||||||
<div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
|
</TableBody>
|
||||||
<div className="overflow-x-auto">
|
</Table>
|
||||||
<Table className="text-base">
|
</div>
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
|
||||||
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{documents.map((d) => (
|
|
||||||
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{d.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<a
|
|
||||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
aria-label={`Download ${d.originalFileName}`}
|
|
||||||
title={`Download ${d.originalFileName}`}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "Salary & Loans" && (
|
{tab === "Salary & Loans" && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
|
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
|
||||||
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
|
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
|
||||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
|
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
|
||||||
@@ -692,63 +644,37 @@ function EmployeeDetailPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{salaryStructures.length === 0 ? (
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
|
<div className="overflow-x-auto">
|
||||||
) : (
|
<Table className="text-base">
|
||||||
<>
|
<TableHeader>
|
||||||
{/* Mobile: stacked cards. */}
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<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) => (
|
{salaryStructures.map((s) => (
|
||||||
<div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
|
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
||||||
<div className="flex items-center justify-between">
|
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||||
<span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
|
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
||||||
<span className="text-muted-foreground">{s.status}</span>
|
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-2 border-t pt-2">
|
</TableRow>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
{/* Desktop: table. */}
|
)}
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
</TableBody>
|
||||||
<div className="overflow-x-auto">
|
</Table>
|
||||||
<Table className="text-base">
|
</div>
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{salaryStructures.map((s) => (
|
|
||||||
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
||||||
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
||||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
|
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
|
||||||
@@ -784,61 +710,35 @@ function EmployeeDetailPageContent() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
{loans.length === 0 ? (
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
|
<div className="overflow-x-auto">
|
||||||
) : (
|
<Table className="text-base">
|
||||||
<>
|
<TableHeader>
|
||||||
{/* Mobile: stacked cards. */}
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<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) => (
|
{loans.map((l) => (
|
||||||
<div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
|
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
||||||
<div className="flex items-center justify-between">
|
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
||||||
<span className="font-medium text-foreground">{l.docNo}</span>
|
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
||||||
<span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
|
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-2 border-t pt-2">
|
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
||||||
<div>
|
</TableRow>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
{/* Desktop: table. */}
|
)}
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
</TableBody>
|
||||||
<div className="overflow-x-auto">
|
</Table>
|
||||||
<Table className="text-base">
|
</div>
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loans.map((l) => (
|
|
||||||
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sticky bottom-0 -mx-4 flex 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">
|
<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" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
|
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
{submitting ? "Creating…" : "Create employee"}
|
{submitting ? "Creating…" : "Create employee"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
||||||
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { employeesApi } from "@/lib/api/employees"
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
|
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
|
||||||
@@ -16,10 +15,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
|
|||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
|
||||||
import { FilterBar } from "@/components/ui/filter-bar"
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
@@ -29,12 +26,8 @@ import { toast } from "@/components/ui/toast"
|
|||||||
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
|
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function EmployeesPage() {
|
export default function EmployeesPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
|
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
|
||||||
@@ -46,7 +39,6 @@ export default function EmployeesPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
|
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
|
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
|
||||||
|
|
||||||
@@ -70,12 +62,6 @@ export default function EmployeesPage() {
|
|||||||
})
|
})
|
||||||
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
|
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
|
||||||
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
|
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
|
||||||
@@ -274,240 +260,159 @@ export default function EmployeesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && employees !== null && employees.length > 0 && (
|
{!error && employees !== null && employees.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{employees.map((e) => (
|
<TableHeader>
|
||||||
<div
|
<TableRow className="hover:bg-transparent">
|
||||||
key={e.employeeId}
|
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
|
||||||
role="link"
|
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
|
||||||
tabIndex={0}
|
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
|
||||||
onClick={(ev) => {
|
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
|
||||||
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
|
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||||
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
|
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||||
}}
|
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||||
onKeyDown={(ev) => {
|
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||||
if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
|
</TableRow>
|
||||||
}}
|
</TableHeader>
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
<TableBody>
|
||||||
>
|
{employees.map((e) => (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
|
||||||
<div className="min-w-0">
|
<TableCell className="px-3 py-4">
|
||||||
<p className="truncate font-semibold text-foreground">{e.fullName}</p>
|
<Link
|
||||||
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||||
</div>
|
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
<Badge
|
>
|
||||||
variant="outline"
|
{e.fullName}
|
||||||
className={cn(
|
</Link>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
</TableCell>
|
||||||
e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{e.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-3 py-4">
|
||||||
<div>
|
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||||
<p className="text-muted-foreground">Department</p>
|
</TableCell>
|
||||||
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
|
|
||||||
</div>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Designation</p>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
|
||||||
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
|
|
||||||
</div>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Hire Date</p>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</TableCell>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
<TableCell className="px-3 py-4">
|
||||||
<Link
|
{e.hasUserLink ? (
|
||||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
<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>
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
) : (
|
||||||
aria-label={`View ${e.fullName}`}
|
<span className="text-xs text-muted-foreground">No login</span>
|
||||||
title={`View ${e.fullName}`}
|
)}
|
||||||
>
|
</TableCell>
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Edit ${e.fullName}`}
|
|
||||||
title={`Edit ${e.fullName}`}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
|
||||||
aria-label={`Delete ${e.fullName}`}
|
|
||||||
title={`Delete ${e.fullName}`}
|
|
||||||
disabled={actionPendingId === e.employeeId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${e.fullName}?`}
|
|
||||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDeleteRequest(e)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && employees.length < pagination.totalItems && (
|
<TableCell className="px-3 py-4">
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
<Badge
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
variant="outline"
|
||||||
</Button>
|
className={cn(
|
||||||
)}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</div>
|
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
|
|
||||||
{/* 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>
|
>
|
||||||
|
{e.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="px-3 py-4">
|
<TableCell className="px-3 py-4">
|
||||||
<Badge
|
<div className="flex items-center justify-center gap-1">
|
||||||
variant="outline"
|
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||||
className={cn(
|
<Eye className="size-4" />
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
</Link>
|
||||||
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
<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>
|
||||||
{e.status}
|
<AlertDialog>
|
||||||
</Badge>
|
<AlertDialogTrigger
|
||||||
</TableCell>
|
render={
|
||||||
|
<Button
|
||||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
variant="ghost"
|
||||||
<div className="flex items-center justify-center gap-1">
|
size="icon-sm"
|
||||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
<Eye className="size-4" />
|
aria-label={`Delete ${e.fullName}`}
|
||||||
</Link>
|
title={`Delete ${e.fullName}`}
|
||||||
<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}`}>
|
disabled={actionPendingId === e.employeeId}
|
||||||
<Pencil className="size-4" />
|
/>
|
||||||
</Link>
|
}
|
||||||
<AlertDialog>
|
>
|
||||||
<AlertDialogTrigger
|
<Trash2 className="size-4" />
|
||||||
render={
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
variant="ghost"
|
variant="destructive"
|
||||||
size="icon-sm"
|
title={`Delete ${e.fullName}?`}
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
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."
|
||||||
aria-label={`Delete ${e.fullName}`}
|
confirmLabel="Delete"
|
||||||
title={`Delete ${e.fullName}`}
|
onConfirm={() => handleDeleteRequest(e)}
|
||||||
disabled={actionPendingId === e.employeeId}
|
/>
|
||||||
/>
|
</AlertDialog>
|
||||||
}
|
</div>
|
||||||
>
|
</TableCell>
|
||||||
<Trash2 className="size-4" />
|
</TableRow>
|
||||||
</AlertDialogTrigger>
|
))}
|
||||||
<AlertDialogContent
|
</TableBody>
|
||||||
variant="destructive"
|
</Table>
|
||||||
title={`Delete ${e.fullName}?`}
|
|
||||||
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDeleteRequest(e)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { employeesApi } from "@/lib/api/employees"
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||||
@@ -18,7 +18,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -26,9 +25,6 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
|
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
|
||||||
const ALL_STATUS = "__all__"
|
const ALL_STATUS = "__all__"
|
||||||
|
|
||||||
@@ -51,7 +47,6 @@ export default function LeaveRequestsPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
|
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
|
||||||
|
|
||||||
@@ -81,12 +76,6 @@ export default function LeaveRequestsPage() {
|
|||||||
})
|
})
|
||||||
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
|
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -314,155 +303,124 @@ export default function LeaveRequestsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && requests !== null && requests.length > 0 && (
|
{!error && requests !== null && requests.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{requests.map((r) => (
|
<TableHeader>
|
||||||
<div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
|
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
|
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
{r.status}
|
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||||
</Badge>
|
</TableRow>
|
||||||
</div>
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
{requests.map((r) => (
|
||||||
<div>
|
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
|
||||||
<p className="text-muted-foreground">Type</p>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||||
<p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
|
||||||
<div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
<p className="text-muted-foreground">Days</p>
|
|
||||||
<p className="font-medium text-foreground">{r.daysCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Dates</p>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
|
||||||
</div>
|
<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 === "Submitted" && (
|
{r.status}
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
</Badge>
|
||||||
<Button
|
</TableCell>
|
||||||
type="button"
|
<TableCell className="px-4 py-4">
|
||||||
variant="ghost"
|
{r.status === "Submitted" ? (
|
||||||
size="icon-sm"
|
<div className="flex items-center justify-center gap-1">
|
||||||
className="text-success hover:bg-success/10"
|
<Button
|
||||||
aria-label={`Approve ${r.docNo}`}
|
type="button"
|
||||||
title={`Approve ${r.docNo}`}
|
variant="ghost"
|
||||||
disabled={actionPendingId === r.leaveRequestId}
|
size="icon-sm"
|
||||||
onClick={() => approve(r.leaveRequestId)}
|
className="text-success hover:bg-success/10"
|
||||||
>
|
aria-label={`Approve ${r.docNo}`}
|
||||||
<CheckCircle2 className="size-4" />
|
title={`Approve ${r.docNo}`}
|
||||||
</Button>
|
disabled={actionPendingId === r.leaveRequestId}
|
||||||
<Button
|
onClick={() => approve(r.leaveRequestId)}
|
||||||
type="button"
|
>
|
||||||
variant="ghost"
|
<CheckCircle2 className="size-4" />
|
||||||
size="icon-sm"
|
</Button>
|
||||||
className="text-destructive hover:bg-destructive/10"
|
<Button
|
||||||
aria-label={`Reject ${r.docNo}`}
|
type="button"
|
||||||
title={`Reject ${r.docNo}`}
|
variant="ghost"
|
||||||
disabled={actionPendingId === r.leaveRequestId}
|
size="icon-sm"
|
||||||
onClick={() => openReject(r)}
|
className="text-destructive hover:bg-destructive/10"
|
||||||
>
|
aria-label={`Reject ${r.docNo}`}
|
||||||
<XCircle className="size-4" />
|
title={`Reject ${r.docNo}`}
|
||||||
</Button>
|
disabled={actionPendingId === r.leaveRequestId}
|
||||||
</div>
|
onClick={() => openReject(r)}
|
||||||
)}
|
>
|
||||||
</div>
|
<XCircle className="size-4" />
|
||||||
))}
|
</Button>
|
||||||
|
</div>
|
||||||
{pagination && requests.length < pagination.totalItems && (
|
) : (
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
<span className="flex justify-center text-xs text-muted-foreground">—</span>
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
)}
|
||||||
</Button>
|
</TableCell>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="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>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{requests.map((r) => (
|
</Table>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||||
<div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex items-start justify-between gap-4 border-b border-border pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
||||||
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
|
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm text-muted-foreground">Period</p>
|
<p className="text-sm text-muted-foreground">Period</p>
|
||||||
<p className="font-semibold text-foreground">{period}</p>
|
<p className="font-semibold text-foreground">{period}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import Link from "next/link"
|
||||||
import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
|
import { useParams } from "next/navigation"
|
||||||
|
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
|
||||||
|
|
||||||
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -14,38 +15,18 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size
|
|
||||||
// client-side, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
function money(n: number): string {
|
function money(n: number): string {
|
||||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small tinted stat chip — value + label + icon. */
|
|
||||||
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Icon className="size-4.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PayrollRunDetailPage() {
|
export default function PayrollRunDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
|
||||||
const runId = Number(params.id)
|
const runId = Number(params.id)
|
||||||
|
|
||||||
const [run, setRun] = useState<PayrollRun | null>(null)
|
const [run, setRun] = useState<PayrollRun | null>(null)
|
||||||
@@ -155,11 +136,11 @@ export default function PayrollRunDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
|
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div>
|
||||||
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
|
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div>
|
||||||
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
|
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div>
|
||||||
<StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
|
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -196,146 +177,114 @@ export default function PayrollRunDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{pagedLines.map((l) => {
|
<TableHeader>
|
||||||
const totalDeductions = l.grossSalary - l.netSalary
|
<TableRow className="hover:bg-transparent">
|
||||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||||
return (
|
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||||
<div
|
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
||||||
key={l.payrollLineId}
|
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
||||||
role="link"
|
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
||||||
tabIndex={0}
|
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
||||||
onClick={(e) => {
|
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
</TableRow>
|
||||||
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
|
</TableHeader>
|
||||||
}}
|
<TableBody>
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
|
{pagedLines.map((l) => {
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
const totalDeductions = l.grossSalary - l.netSalary
|
||||||
>
|
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||||
<div className="flex items-start justify-between gap-2">
|
return (
|
||||||
<div className="min-w-0">
|
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
|
||||||
<p className="truncate font-semibold text-foreground">{l.employeeName}</p>
|
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
||||||
<span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
|
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
||||||
</div>
|
</TableCell>
|
||||||
{payslip && (
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
||||||
<a
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
||||||
href={payslipViewUrl(payslip.payslipId)}
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
||||||
target="_blank"
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
||||||
rel="noreferrer"
|
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
||||||
data-no-row-nav
|
<TableCell className="px-4 py-3.5">
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
|
<div className="flex items-center justify-center gap-1">
|
||||||
aria-label={`View payslip for ${l.employeeName}`}
|
<Link
|
||||||
title="View payslip"
|
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
|
||||||
>
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
<FileText className="size-4" />
|
aria-label={`View breakdown for ${l.employeeName}`}
|
||||||
</a>
|
title="View breakdown"
|
||||||
)}
|
>
|
||||||
</div>
|
<Eye className="size-4" />
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
</Link>
|
||||||
<div>
|
{payslip && (
|
||||||
<p className="text-muted-foreground">Basic</p>
|
<a
|
||||||
<p className="font-medium text-foreground">{money(l.basicSalary)}</p>
|
href={payslipViewUrl(payslip.payslipId)}
|
||||||
</div>
|
target="_blank"
|
||||||
<div>
|
rel="noreferrer"
|
||||||
<p className="text-muted-foreground">OT</p>
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
<p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
|
aria-label={`View payslip for ${l.employeeName}`}
|
||||||
</div>
|
title="View payslip"
|
||||||
<div>
|
>
|
||||||
<p className="text-muted-foreground">Allowances</p>
|
<FileText className="size-4" />
|
||||||
<p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
|
</a>
|
||||||
</div>
|
)}
|
||||||
<div>
|
</div>
|
||||||
<p className="text-muted-foreground">Deductions</p>
|
</TableCell>
|
||||||
<p className="font-medium text-foreground">{money(totalDeductions)}</p>
|
</TableRow>
|
||||||
</div>
|
)
|
||||||
<div className="col-span-2">
|
})}
|
||||||
<p className="text-muted-foreground">Net Salary</p>
|
</TableBody>
|
||||||
<p className="font-semibold text-foreground">{money(l.netSalary)}</p>
|
</Table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{filteredLines.length > pagedLines.length && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
|
|
||||||
Show more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
|
||||||
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pagedLines.map((l) => {
|
|
||||||
const totalDeductions = l.grossSalary - l.netSalary
|
|
||||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={l.payrollLineId}
|
|
||||||
className="cursor-pointer hover:bg-muted/20"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
|
||||||
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3.5" data-no-row-nav>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
{payslip && (
|
|
||||||
<a
|
|
||||||
href={payslipViewUrl(payslip.payslipId)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View payslip for ${l.employeeName}`}
|
|
||||||
title="View payslip"
|
|
||||||
>
|
|
||||||
<FileText className="size-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredLines.length > 0 && (
|
{filteredLines.length > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
|
||||||
/>
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import Link from "next/link"
|
||||||
import { Plus } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
|
||||||
|
|
||||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -10,21 +10,17 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
|
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
|
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
|
||||||
const ALL_STATUS = "__all__"
|
const ALL_STATUS = "__all__"
|
||||||
|
|
||||||
@@ -35,14 +31,12 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PayrollRunsPage() {
|
export default function PayrollRunsPage() {
|
||||||
const router = useRouter()
|
|
||||||
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
|
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(5)
|
const [pageSize, setPageSize] = useState(5)
|
||||||
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -55,12 +49,6 @@ export default function PayrollRunsPage() {
|
|||||||
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
|
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
|
||||||
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -153,99 +141,102 @@ export default function PayrollRunsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && runs !== null && runs.length > 0 && (
|
{!error && runs !== null && runs.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{runs.map((r) => (
|
<TableHeader>
|
||||||
<div
|
<TableRow className="hover:bg-transparent">
|
||||||
key={r.payrollRunId}
|
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||||
role="link"
|
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
||||||
tabIndex={0}
|
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
||||||
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
|
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
|
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
>
|
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</TableRow>
|
||||||
<div className="min-w-0">
|
</TableHeader>
|
||||||
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
|
<TableBody>
|
||||||
<p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
|
{runs.map((r) => (
|
||||||
</div>
|
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||||
{r.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||||
<div>
|
<TableCell className="px-4 py-4">
|
||||||
<p className="text-muted-foreground">Employees</p>
|
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||||
<p className="font-medium text-foreground">{r.employeeCount}</p>
|
{r.status}
|
||||||
</div>
|
</Badge>
|
||||||
<div>
|
</TableCell>
|
||||||
<p className="text-muted-foreground">Gross</p>
|
<TableCell className="px-4 py-4">
|
||||||
<p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
<div className="flex items-center justify-center">
|
||||||
</div>
|
<Link
|
||||||
<div>
|
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||||
<p className="text-muted-foreground">Net</p>
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
<p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
aria-label={`View ${r.docNo}`}
|
||||||
</div>
|
title={`View ${r.docNo}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Eye className="size-4" />
|
||||||
))}
|
</Link>
|
||||||
|
</div>
|
||||||
{pagination && runs.length < pagination.totalItems && (
|
</TableCell>
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{runs.map((r) => (
|
</Table>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -15,7 +15,6 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -23,9 +22,6 @@ import { toast } from "@/components/ui/toast"
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const NONE = "__none__"
|
const NONE = "__none__"
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function DepartmentsPage() {
|
export default function DepartmentsPage() {
|
||||||
const [items, setItems] = useState<Department[] | null>(null)
|
const [items, setItems] = useState<Department[] | null>(null)
|
||||||
@@ -37,7 +33,6 @@ export default function DepartmentsPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -64,12 +59,6 @@ export default function DepartmentsPage() {
|
|||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -295,9 +284,9 @@ export default function DepartmentsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
<Button variant="outline" className="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}>
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,209 +355,151 @@ export default function DepartmentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="table-fixed text-base">
|
||||||
{items.map((d) => (
|
<TableHeader>
|
||||||
<div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{d.name}</p>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{d.code}</span>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<Badge
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
variant="outline"
|
</TableRow>
|
||||||
className={cn(
|
</TableHeader>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<TableBody>
|
||||||
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
{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>
|
||||||
{d.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Parent</p>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
<div>
|
<Badge
|
||||||
<p className="text-muted-foreground">Created</p>
|
variant="outline"
|
||||||
<p className="font-medium text-foreground">
|
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" })}
|
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${d.name}`}
|
|
||||||
title={`Edit ${d.name}`}
|
|
||||||
onClick={() => openEdit(d)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
d.status === "Active"
|
|
||||||
? "text-destructive hover:bg-destructive/10"
|
|
||||||
: "text-success hover:bg-success/10"
|
|
||||||
)}
|
|
||||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
|
||||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
|
||||||
disabled={actionPendingId === d.departmentId}
|
|
||||||
onClick={() => toggleStatus(d)}
|
|
||||||
>
|
|
||||||
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
aria-label={`Edit ${d.name}`}
|
||||||
aria-label={`Delete ${d.name}`}
|
title={`Edit ${d.name}`}
|
||||||
title={`Delete ${d.name}`}
|
onClick={() => openEdit(d)}
|
||||||
disabled={actionPendingId === d.departmentId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${d.name}?`}
|
|
||||||
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(d)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && items.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="table-fixed text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((d) => (
|
|
||||||
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
|
||||||
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{d.status}
|
<Pencil className="size-4" />
|
||||||
</Badge>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
type="button"
|
||||||
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
variant="ghost"
|
||||||
</TableCell>
|
size="icon-sm"
|
||||||
<TableCell className="px-4 py-4">
|
className={cn(
|
||||||
<div className="flex items-center justify-center gap-1">
|
d.status === "Active"
|
||||||
<Button
|
? "text-destructive hover:bg-destructive/10"
|
||||||
type="button"
|
: "text-success hover:bg-success/10"
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon-sm"
|
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||||
aria-label={`Edit ${d.name}`}
|
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||||
title={`Edit ${d.name}`}
|
disabled={actionPendingId === d.departmentId}
|
||||||
onClick={() => openEdit(d)}
|
onClick={() => toggleStatus(d)}
|
||||||
|
>
|
||||||
|
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${d.name}`}
|
||||||
|
title={`Delete ${d.name}`}
|
||||||
|
disabled={actionPendingId === d.departmentId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant="ghost"
|
title={`Delete ${d.name}?`}
|
||||||
size="icon-sm"
|
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."
|
||||||
className={cn(
|
confirmLabel="Delete"
|
||||||
d.status === "Active"
|
onConfirm={() => handleDelete(d)}
|
||||||
? "text-destructive hover:bg-destructive/10"
|
/>
|
||||||
: "text-success hover:bg-success/10"
|
</AlertDialog>
|
||||||
)}
|
</div>
|
||||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
</TableCell>
|
||||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
</TableRow>
|
||||||
disabled={actionPendingId === d.departmentId}
|
))}
|
||||||
onClick={() => toggleStatus(d)}
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
{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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -16,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -24,9 +23,6 @@ import { toast } from "@/components/ui/toast"
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function DocumentTypesPage() {
|
export default function DocumentTypesPage() {
|
||||||
const [items, setItems] = useState<HrDocumentType[] | null>(null)
|
const [items, setItems] = useState<HrDocumentType[] | null>(null)
|
||||||
@@ -37,7 +33,6 @@ export default function DocumentTypesPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -63,12 +58,6 @@ export default function DocumentTypesPage() {
|
|||||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -271,9 +260,9 @@ export default function DocumentTypesPage() {
|
|||||||
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
<Button variant="outline" className="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}>
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,205 +331,149 @@ export default function DocumentTypesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="table-fixed text-base">
|
||||||
{items.map((t) => (
|
<TableHeader>
|
||||||
<div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<Badge
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
variant="outline"
|
</TableRow>
|
||||||
className={cn(
|
</TableHeader>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<TableBody>
|
||||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
{items.map((t) => (
|
||||||
)}
|
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
||||||
>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||||
{t.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
|
<Badge
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
variant="outline"
|
||||||
<div>
|
className={cn(
|
||||||
<p className="text-muted-foreground">Category</p>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
<p className="font-medium text-foreground">{t.category}</p>
|
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</div>
|
)}
|
||||||
<div>
|
>
|
||||||
<p className="text-muted-foreground">Created</p>
|
{t.status}
|
||||||
<p className="font-medium text-foreground">
|
</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" })}
|
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${t.name}`}
|
|
||||||
title={`Edit ${t.name}`}
|
|
||||||
onClick={() => openEdit(t)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
t.status === "Active"
|
|
||||||
? "text-destructive hover:bg-destructive/10"
|
|
||||||
: "text-success hover:bg-success/10"
|
|
||||||
)}
|
|
||||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
|
||||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
|
||||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
|
||||||
onClick={() => toggleStatus(t)}
|
|
||||||
>
|
|
||||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
aria-label={`Edit ${t.name}`}
|
||||||
aria-label={`Delete ${t.name}`}
|
title={`Edit ${t.name}`}
|
||||||
title={`Delete ${t.name}`}
|
onClick={() => openEdit(t)}
|
||||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${t.name}?`}
|
|
||||||
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(t)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && items.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="table-fixed text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((t) => (
|
|
||||||
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{t.status}
|
<Pencil className="size-4" />
|
||||||
</Badge>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
type="button"
|
||||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
variant="ghost"
|
||||||
</TableCell>
|
size="icon-sm"
|
||||||
<TableCell className="px-4 py-4">
|
className={cn(
|
||||||
<div className="flex items-center justify-center gap-1">
|
t.status === "Active"
|
||||||
<Button
|
? "text-destructive hover:bg-destructive/10"
|
||||||
type="button"
|
: "text-success hover:bg-success/10"
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon-sm"
|
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
aria-label={`Edit ${t.name}`}
|
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
title={`Edit ${t.name}`}
|
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||||
onClick={() => openEdit(t)}
|
onClick={() => toggleStatus(t)}
|
||||||
|
>
|
||||||
|
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${t.name}`}
|
||||||
|
title={`Delete ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant="ghost"
|
title={`Delete ${t.name}?`}
|
||||||
size="icon-sm"
|
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."
|
||||||
className={cn(
|
confirmLabel="Delete"
|
||||||
t.status === "Active"
|
onConfirm={() => handleDelete(t)}
|
||||||
? "text-destructive hover:bg-destructive/10"
|
/>
|
||||||
: "text-success hover:bg-success/10"
|
</AlertDialog>
|
||||||
)}
|
</div>
|
||||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
</TableCell>
|
||||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
</TableRow>
|
||||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
))}
|
||||||
onClick={() => toggleStatus(t)}
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
{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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -16,15 +16,11 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function LeaveTypesPage() {
|
export default function LeaveTypesPage() {
|
||||||
const [items, setItems] = useState<LeaveType[] | null>(null)
|
const [items, setItems] = useState<LeaveType[] | null>(null)
|
||||||
@@ -35,7 +31,6 @@ export default function LeaveTypesPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -64,12 +59,6 @@ export default function LeaveTypesPage() {
|
|||||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -289,9 +278,9 @@ export default function LeaveTypesPage() {
|
|||||||
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
|
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
<Button variant="outline" className="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}>
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,211 +350,151 @@ export default function LeaveTypesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="table-fixed text-base">
|
||||||
{items.map((t) => (
|
<TableHeader>
|
||||||
<div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
|
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
||||||
<Badge
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
||||||
variant="outline"
|
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
||||||
className={cn(
|
</TableRow>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
</TableHeader>
|
||||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
<TableBody>
|
||||||
)}
|
{items.map((t) => (
|
||||||
>
|
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
||||||
{t.status}
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||||
</div>
|
<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>
|
||||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-4 py-4">
|
||||||
<div>
|
<Badge
|
||||||
<p className="text-muted-foreground">Days/yr</p>
|
variant="outline"
|
||||||
<p className="font-medium text-foreground">{t.accrualPerYear}</p>
|
className={cn(
|
||||||
</div>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
<div>
|
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
<p className="text-muted-foreground">Paid</p>
|
)}
|
||||||
<p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
|
>
|
||||||
</div>
|
{t.status}
|
||||||
<div>
|
</Badge>
|
||||||
<p className="text-muted-foreground">Created</p>
|
</TableCell>
|
||||||
<p className="font-medium text-foreground">
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${t.name}`}
|
|
||||||
title={`Edit ${t.name}`}
|
|
||||||
onClick={() => openEdit(t)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
t.status === "Active"
|
|
||||||
? "text-destructive hover:bg-destructive/10"
|
|
||||||
: "text-success hover:bg-success/10"
|
|
||||||
)}
|
|
||||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
|
||||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
|
||||||
disabled={actionPendingId === t.leaveTypeId}
|
|
||||||
onClick={() => toggleStatus(t)}
|
|
||||||
>
|
|
||||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
aria-label={`Edit ${t.name}`}
|
||||||
aria-label={`Delete ${t.name}`}
|
title={`Edit ${t.name}`}
|
||||||
title={`Delete ${t.name}`}
|
onClick={() => openEdit(t)}
|
||||||
disabled={actionPendingId === t.leaveTypeId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${t.name}?`}
|
|
||||||
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(t)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && items.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="table-fixed text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
|
||||||
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
|
||||||
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
|
||||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
|
||||||
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((t) => (
|
|
||||||
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{t.status}
|
<Pencil className="size-4" />
|
||||||
</Badge>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
type="button"
|
||||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
variant="ghost"
|
||||||
</TableCell>
|
size="icon-sm"
|
||||||
<TableCell className="px-4 py-4">
|
className={cn(
|
||||||
<div className="flex items-center justify-center gap-1">
|
t.status === "Active"
|
||||||
<Button
|
? "text-destructive hover:bg-destructive/10"
|
||||||
type="button"
|
: "text-success hover:bg-success/10"
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon-sm"
|
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
aria-label={`Edit ${t.name}`}
|
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
title={`Edit ${t.name}`}
|
disabled={actionPendingId === t.leaveTypeId}
|
||||||
onClick={() => openEdit(t)}
|
onClick={() => toggleStatus(t)}
|
||||||
|
>
|
||||||
|
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${t.name}`}
|
||||||
|
title={`Delete ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.leaveTypeId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant="ghost"
|
title={`Delete ${t.name}?`}
|
||||||
size="icon-sm"
|
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."
|
||||||
className={cn(
|
confirmLabel="Delete"
|
||||||
t.status === "Active"
|
onConfirm={() => handleDelete(t)}
|
||||||
? "text-destructive hover:bg-destructive/10"
|
/>
|
||||||
: "text-success hover:bg-success/10"
|
</AlertDialog>
|
||||||
)}
|
</div>
|
||||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
</TableCell>
|
||||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
</TableRow>
|
||||||
disabled={actionPendingId === t.leaveTypeId}
|
))}
|
||||||
onClick={() => toggleStatus(t)}
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
{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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function SalaryComponentsPage() {
|
export default function SalaryComponentsPage() {
|
||||||
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
||||||
@@ -36,7 +32,6 @@ export default function SalaryComponentsPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -61,12 +56,6 @@ export default function SalaryComponentsPage() {
|
|||||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -264,9 +253,9 @@ export default function SalaryComponentsPage() {
|
|||||||
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
|
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
<Button variant="outline" className="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}>
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,205 +324,149 @@ export default function SalaryComponentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="table-fixed text-base">
|
||||||
{items.map((c) => (
|
<TableHeader>
|
||||||
<div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{c.name}</p>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{c.code}</span>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<Badge
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
variant="outline"
|
</TableRow>
|
||||||
className={cn(
|
</TableHeader>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<TableBody>
|
||||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
{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>
|
||||||
{c.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
|
<Badge
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
variant="outline"
|
||||||
<div>
|
className={cn(
|
||||||
<p className="text-muted-foreground">Type</p>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
<p className="font-medium text-foreground">{c.componentType}</p>
|
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</div>
|
)}
|
||||||
<div>
|
>
|
||||||
<p className="text-muted-foreground">Created</p>
|
{c.status}
|
||||||
<p className="font-medium text-foreground">
|
</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" })}
|
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${c.name}`}
|
|
||||||
title={`Edit ${c.name}`}
|
|
||||||
onClick={() => openEdit(c)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
c.status === "Active"
|
|
||||||
? "text-destructive hover:bg-destructive/10"
|
|
||||||
: "text-success hover:bg-success/10"
|
|
||||||
)}
|
|
||||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
|
||||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
|
||||||
disabled={actionPendingId === c.salaryComponentId}
|
|
||||||
onClick={() => toggleStatus(c)}
|
|
||||||
>
|
|
||||||
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
aria-label={`Edit ${c.name}`}
|
||||||
aria-label={`Delete ${c.name}`}
|
title={`Edit ${c.name}`}
|
||||||
title={`Delete ${c.name}`}
|
onClick={() => openEdit(c)}
|
||||||
disabled={actionPendingId === c.salaryComponentId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${c.name}?`}
|
|
||||||
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(c)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && items.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="table-fixed text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((c) => (
|
|
||||||
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{c.status}
|
<Pencil className="size-4" />
|
||||||
</Badge>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
type="button"
|
||||||
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
variant="ghost"
|
||||||
</TableCell>
|
size="icon-sm"
|
||||||
<TableCell className="px-4 py-4">
|
className={cn(
|
||||||
<div className="flex items-center justify-center gap-1">
|
c.status === "Active"
|
||||||
<Button
|
? "text-destructive hover:bg-destructive/10"
|
||||||
type="button"
|
: "text-success hover:bg-success/10"
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon-sm"
|
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||||
aria-label={`Edit ${c.name}`}
|
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||||
title={`Edit ${c.name}`}
|
disabled={actionPendingId === c.salaryComponentId}
|
||||||
onClick={() => openEdit(c)}
|
onClick={() => toggleStatus(c)}
|
||||||
|
>
|
||||||
|
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${c.name}`}
|
||||||
|
title={`Delete ${c.name}`}
|
||||||
|
disabled={actionPendingId === c.salaryComponentId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant="ghost"
|
title={`Delete ${c.name}?`}
|
||||||
size="icon-sm"
|
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."
|
||||||
className={cn(
|
confirmLabel="Delete"
|
||||||
c.status === "Active"
|
onConfirm={() => handleDelete(c)}
|
||||||
? "text-destructive hover:bg-destructive/10"
|
/>
|
||||||
: "text-success hover:bg-success/10"
|
</AlertDialog>
|
||||||
)}
|
</div>
|
||||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
</TableCell>
|
||||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
</TableRow>
|
||||||
disabled={actionPendingId === c.salaryComponentId}
|
))}
|
||||||
onClick={() => toggleStatus(c)}
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
{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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -141,66 +141,34 @@ export default function StatutorySettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{settings !== null && settings.length > 0 && (
|
{settings !== null && settings.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{settings.map((s) => (
|
<TableHeader>
|
||||||
<div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<p className="font-semibold text-foreground">
|
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||||
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
||||||
</p>
|
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
||||||
<div>
|
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
||||||
<p className="text-muted-foreground">EPF (Employee)</p>
|
</TableRow>
|
||||||
<p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
|
</TableHeader>
|
||||||
</div>
|
<TableBody>
|
||||||
<div>
|
{settings.map((s) => (
|
||||||
<p className="text-muted-foreground">EPF (Employer)</p>
|
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
|
||||||
<p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
|
<TableCell className="px-4 py-4 text-sm text-foreground">
|
||||||
</div>
|
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||||
<div>
|
</TableCell>
|
||||||
<p className="text-muted-foreground">ETF (Employer)</p>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||||
<p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||||
<div>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
|
||||||
<p className="text-muted-foreground">OT multiplier</p>
|
|
||||||
<p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
|
||||||
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{settings.map((s) => (
|
</Table>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -257,46 +225,28 @@ export default function StatutorySettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{slabs !== null && slabs.length > 0 && (
|
{slabs !== null && slabs.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{slabs.map((s) => (
|
<TableHeader>
|
||||||
<div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||||
<p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
|
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
|
||||||
<p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
|
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
<p className="border-t pt-3 text-sm text-muted-foreground">
|
</TableHeader>
|
||||||
{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
|
<TableBody>
|
||||||
</p>
|
{slabs.map((s) => (
|
||||||
</div>
|
<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>
|
||||||
</div>
|
<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>
|
||||||
{/* 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>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{slabs.map((s) => (
|
</Table>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
function maskToDays(mask: number): boolean[] {
|
function maskToDays(mask: number): boolean[] {
|
||||||
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
|
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
|
||||||
@@ -40,7 +36,6 @@ export default function WorkShiftsPage() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -79,12 +74,6 @@ export default function WorkShiftsPage() {
|
|||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -228,7 +217,7 @@ export default function WorkShiftsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
|
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New Work Shift</DialogTitle>
|
<DialogTitle>New Work Shift</DialogTitle>
|
||||||
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
|
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
|
||||||
@@ -297,7 +286,7 @@ export default function WorkShiftsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>Edit Work Shift</DialogTitle>
|
<DialogTitle>Edit Work Shift</DialogTitle>
|
||||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
@@ -366,9 +355,9 @@ export default function WorkShiftsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
<Button variant="outline" className="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}>
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,205 +426,149 @@ export default function WorkShiftsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="table-fixed text-base">
|
||||||
{items.map((w) => (
|
<TableHeader>
|
||||||
<div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<p className="truncate font-semibold text-foreground">{w.name}</p>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{w.code}</span>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</div>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<Badge
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
variant="outline"
|
</TableRow>
|
||||||
className={cn(
|
</TableHeader>
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<TableBody>
|
||||||
w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
{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>
|
||||||
{w.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
|
<Badge
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
variant="outline"
|
||||||
<div>
|
className={cn(
|
||||||
<p className="text-muted-foreground">Hours</p>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
<p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
|
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</div>
|
)}
|
||||||
<div>
|
>
|
||||||
<p className="text-muted-foreground">Created</p>
|
{w.status}
|
||||||
<p className="font-medium text-foreground">
|
</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" })}
|
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-4">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${w.name}`}
|
|
||||||
title={`Edit ${w.name}`}
|
|
||||||
onClick={() => openEdit(w)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
w.status === "Active"
|
|
||||||
? "text-destructive hover:bg-destructive/10"
|
|
||||||
: "text-success hover:bg-success/10"
|
|
||||||
)}
|
|
||||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
|
||||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
|
||||||
disabled={actionPendingId === w.workShiftId}
|
|
||||||
onClick={() => toggleStatus(w)}
|
|
||||||
>
|
|
||||||
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
aria-label={`Edit ${w.name}`}
|
||||||
aria-label={`Delete ${w.name}`}
|
title={`Edit ${w.name}`}
|
||||||
title={`Delete ${w.name}`}
|
onClick={() => openEdit(w)}
|
||||||
disabled={actionPendingId === w.workShiftId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete ${w.name}?`}
|
|
||||||
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(w)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && items.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="table-fixed text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
|
||||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((w) => (
|
|
||||||
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
|
||||||
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{w.status}
|
<Pencil className="size-4" />
|
||||||
</Badge>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
type="button"
|
||||||
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
variant="ghost"
|
||||||
</TableCell>
|
size="icon-sm"
|
||||||
<TableCell className="px-4 py-4">
|
className={cn(
|
||||||
<div className="flex items-center justify-center gap-1">
|
w.status === "Active"
|
||||||
<Button
|
? "text-destructive hover:bg-destructive/10"
|
||||||
type="button"
|
: "text-success hover:bg-success/10"
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon-sm"
|
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||||
aria-label={`Edit ${w.name}`}
|
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||||
title={`Edit ${w.name}`}
|
disabled={actionPendingId === w.workShiftId}
|
||||||
onClick={() => openEdit(w)}
|
onClick={() => toggleStatus(w)}
|
||||||
|
>
|
||||||
|
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${w.name}`}
|
||||||
|
title={`Delete ${w.name}`}
|
||||||
|
disabled={actionPendingId === w.workShiftId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</AlertDialogTrigger>
|
||||||
<Button
|
<AlertDialogContent
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant="ghost"
|
title={`Delete ${w.name}?`}
|
||||||
size="icon-sm"
|
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."
|
||||||
className={cn(
|
confirmLabel="Delete"
|
||||||
w.status === "Active"
|
onConfirm={() => handleDelete(w)}
|
||||||
? "text-destructive hover:bg-destructive/10"
|
/>
|
||||||
: "text-success hover:bg-success/10"
|
</AlertDialog>
|
||||||
)}
|
</div>
|
||||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
</TableCell>
|
||||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
</TableRow>
|
||||||
disabled={actionPendingId === w.workShiftId}
|
))}
|
||||||
onClick={() => toggleStatus(w)}
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
{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>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalItems > 0 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<PaginationFooter
|
<div className="flex items-center gap-3">
|
||||||
pagination={pagination}
|
<p className="text-sm text-muted-foreground">
|
||||||
onPageChange={setPage}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<span className="font-medium text-foreground">
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
/>
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && report !== null && !isEmpty && (
|
{!error && report !== null && !isEmpty && (
|
||||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||||
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && rows !== null && rows.length > 0 && (
|
{!error && rows !== null && rows.length > 0 && (
|
||||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||||
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
|
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
|
||||||
<div className="mt-4 overflow-x-auto">
|
<Table className="mt-4 text-base">
|
||||||
<Table className="text-base">
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
|
||||||
<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">Account Name</TableHead>
|
<TableHead className="h-11 px-3 text-sm">Date</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-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">Debit</TableHead>
|
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
||||||
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
||||||
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
||||||
{[...rows]
|
{[...rows]
|
||||||
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
||||||
.map((row, i) => (
|
.map((row, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
|
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||||
</TableRow>
|
|
||||||
))}
|
</TableRow>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && report !== null && !isEmpty && (
|
{!error && report !== null && !isEmpty && (
|
||||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||||
<ReportHeader
|
<ReportHeader
|
||||||
title="Statement of Profit or Loss"
|
title="Statement of Profit or Loss"
|
||||||
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
|
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && report !== null && (
|
{!error && report !== null && (
|
||||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||||
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
|
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
|
||||||
identity block (company name/address/TIN/BRN) that today lives only in GL's own
|
identity block (company name/address/TIN/BRN) that today lives only in GL's own
|
||||||
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
|
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
|
||||||
|
|||||||
@@ -97,38 +97,36 @@ export default function TrialBalancePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && rows !== null && rows.length > 0 && (
|
{!error && rows !== null && rows.length > 0 && (
|
||||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||||
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
|
||||||
<div className="mt-4 overflow-x-auto">
|
<Table className="mt-4 text-base">
|
||||||
<Table className="text-base">
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
|
||||||
<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">Debit</TableHead>
|
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="px-3 py-2.5">
|
||||||
|
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
|
||||||
|
<span>{row.accountName}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{rows.map((row, i) => (
|
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
|
||||||
<TableRow key={i}>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell className="px-3 py-2.5">
|
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
|
||||||
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
|
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
|
||||||
<span>{row.accountName}</span>
|
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
|
</TableFooter>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
|
</Table>
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { useEffect, useMemo, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDownLeft,
|
|
||||||
ArrowUpRight,
|
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Clock,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -129,7 +128,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/dashboard/stock/reorder-alerts">
|
<Link href="/dashboard/stock/reorder-alerts">
|
||||||
@@ -138,6 +137,9 @@ export default function DashboardPage() {
|
|||||||
<Link href="/dashboard/stock/enquiry">
|
<Link href="/dashboard/stock/enquiry">
|
||||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/dashboard/procurement/purchase-orders">
|
||||||
|
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
||||||
|
</Link>
|
||||||
<Link href="/dashboard/receiving/grn">
|
<Link href="/dashboard/receiving/grn">
|
||||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -152,68 +154,66 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
Stock Valuation by Warehouse
|
||||||
Stock Valuation by Warehouse
|
</h2>
|
||||||
</h2>
|
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
View details
|
||||||
View details
|
</Link>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loaded ? (
|
|
||||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
|
||||||
<div className="h-64 sm:h-72">
|
|
||||||
<BarChart
|
|
||||||
labels={stats.stockValuationByWarehouse.map(
|
|
||||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
|
||||||
)}
|
|
||||||
datasets={[
|
|
||||||
{
|
|
||||||
label: "Stock Value (LKR)",
|
|
||||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
|
||||||
backgroundColor: "#E11D48",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
{loaded ? (
|
||||||
<div className="mb-4 flex items-center gap-2">
|
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
<div className="h-52 sm:h-64">
|
||||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
<BarChart
|
||||||
Stock Movement Trend (last {TREND_DAYS} days)
|
labels={stats.stockValuationByWarehouse.map(
|
||||||
</h2>
|
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{loaded ? (
|
|
||||||
<div className="h-64 sm:h-72">
|
|
||||||
<LineChart
|
|
||||||
labels={movementTrend.labels}
|
|
||||||
datasets={[
|
datasets={[
|
||||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
{
|
||||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
label: "Stock Value (LKR)",
|
||||||
|
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||||
)}
|
)
|
||||||
|
) : (
|
||||||
|
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||||
|
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
|
Stock Movement Trend (last {TREND_DAYS} days)
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loaded ? (
|
||||||
|
<div className="h-52 sm:h-64">
|
||||||
|
<LineChart
|
||||||
|
labels={movementTrend.labels}
|
||||||
|
datasets={[
|
||||||
|
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||||
|
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
@@ -229,111 +229,48 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
movements.length > 0 ? (
|
movements.length > 0 ? (
|
||||||
<>
|
<Table>
|
||||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<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) => (
|
{movements.map((entry) => (
|
||||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
<TableRow key={entry.ledgerId}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<TableCell className="text-muted-foreground">
|
||||||
<div
|
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
|
||||||
className={cn(
|
</TableCell>
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
<TableCell>
|
||||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.direction === "In" ? (
|
|
||||||
<ArrowDownLeft className="size-4 text-success" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpRight className="size-4 text-destructive" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
"h-6 w-16 justify-center text-xs",
|
||||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
entry.direction === "In"
|
||||||
|
? "border-transparent bg-success/10 text-success"
|
||||||
|
: "border-transparent bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{entry.direction}
|
{entry.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableCell className="text-muted-foreground">
|
||||||
<div>
|
{entry.sourceDocType} #{entry.sourceDocId}
|
||||||
<p className="text-muted-foreground">Qty</p>
|
</TableCell>
|
||||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
<TableCell className="text-right text-muted-foreground">
|
||||||
</div>
|
{new Date(entry.createdAt).toLocaleDateString()}
|
||||||
<div>
|
</TableCell>
|
||||||
<p className="text-muted-foreground">Date</p>
|
</TableRow>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="min-w-160">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Item</TableHead>
|
|
||||||
<TableHead>Warehouse</TableHead>
|
|
||||||
<TableHead>Direction</TableHead>
|
|
||||||
<TableHead>Qty</TableHead>
|
|
||||||
<TableHead>Source</TableHead>
|
|
||||||
<TableHead className="text-right">Date</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{movements.map((entry) => (
|
|
||||||
<TableRow key={entry.ledgerId}>
|
|
||||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-16 justify-center text-xs",
|
|
||||||
entry.direction === "In"
|
|
||||||
? "border-transparent bg-success/10 text-success"
|
|
||||||
: "border-transparent bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.direction}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{entry.qtyBase}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{entry.sourceDocType} #{entry.sourceDocId}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{new Date(entry.createdAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{invoice.status !== "Draft" && (
|
{invoice.status !== "Draft" && (
|
||||||
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
<BookOpen className="size-5" />
|
<BookOpen className="size-5" />
|
||||||
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
<section className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||||
<div className="border-b pb-4">
|
<div className="border-b pb-4">
|
||||||
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
|
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
|
||||||
<div>
|
<div>
|
||||||
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
{line.grnDocNo}
|
{line.grnDocNo}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
||||||
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
|
<Button size="lg" type="button" onClick={submit} disabled={saving}>
|
||||||
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
|
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,33 +2,28 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
||||||
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
|
|
||||||
|
|
||||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { dashboardApi } from "@/lib/api/dashboard"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
|
||||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||||
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { Branch } from "@/types/hrm"
|
import { Branch } from "@/types/hrm"
|
||||||
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
|
|
||||||
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type StatusFilter = PurchaseInvoiceStatus | "All"
|
type StatusFilter = PurchaseInvoiceStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 10
|
||||||
const tabs: { value: StatusFilter; label: string }[] = [
|
const tabs: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -37,7 +32,6 @@ const tabs: { value: StatusFilter; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function PurchaseInvoicesPage() {
|
export default function PurchaseInvoicesPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||||
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
|
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
@@ -50,15 +44,6 @@ export default function PurchaseInvoicesPage() {
|
|||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
|
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||||
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
|
||||||
dashboardApi
|
|
||||||
.stats({ branchId })
|
|
||||||
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
|
|
||||||
.catch(() => setMonthlyPurchases([]))
|
|
||||||
}, [activeBranchId, branchingEnabled])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
@@ -154,30 +139,6 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<TrendingUp className="size-4 shrink-0 text-primary" />
|
|
||||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{monthlyPurchases === null ? (
|
|
||||||
<Skeleton className="h-64 rounded-lg sm:h-72" />
|
|
||||||
) : monthlyPurchases.length > 0 ? (
|
|
||||||
<div className="h-64 sm:h-72">
|
|
||||||
<BarChart
|
|
||||||
labels={monthlyPurchases.map((m) =>
|
|
||||||
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
|
|
||||||
)}
|
|
||||||
datasets={[
|
|
||||||
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card shadow-sm">
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -228,99 +189,7 @@ export default function PurchaseInvoicesPage() {
|
|||||||
|
|
||||||
{!error && rows !== null && visibleRows.length > 0 && (
|
{!error && rows !== null && visibleRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3 p-4 md:hidden">
|
<div className="overflow-x-auto">
|
||||||
{visibleRows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.purchaseInvoiceId}
|
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
|
|
||||||
data-no-row-nav
|
|
||||||
className="font-semibold text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{row.docNo}
|
|
||||||
</Link>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{row.vendorSnapshotName}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
||||||
<PurchaseInvoiceStatusBadge status={row.status} />
|
|
||||||
{row.status !== "Draft" && (
|
|
||||||
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Vendor invoice no.</p>
|
|
||||||
<p className="font-medium text-foreground">{row.vendorInvoiceNo}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Date</p>
|
|
||||||
<p className="font-medium text-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">GRNs</p>
|
|
||||||
<p className="font-medium text-foreground tabular-nums">{row.grnCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Total</p>
|
|
||||||
<p className="font-semibold text-foreground tabular-nums">{row.totals.grandTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
{isAdmin && branchingEnabled && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Branch</p>
|
|
||||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
|
||||||
{row.status === "Draft" && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={confirmingId === row.purchaseInvoiceId}
|
|
||||||
onClick={() => handleConfirm(row)}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.totals.balanceAmount > 0 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setPayDialogInvoice(row)
|
|
||||||
setPayDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircleDollarSign className="size-4" />
|
|
||||||
Pay
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -339,14 +208,7 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleRows.map((row) => (
|
{visibleRows.map((row) => (
|
||||||
<TableRow
|
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
|
||||||
key={row.purchaseInvoiceId}
|
|
||||||
className="cursor-pointer hover:bg-muted/40"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className="px-4 py-3.5 font-medium">
|
<TableCell className="px-4 py-3.5 font-medium">
|
||||||
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
|
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
|
||||||
{row.docNo}
|
{row.docNo}
|
||||||
@@ -354,10 +216,10 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
{isAdmin && branchingEnabled && (
|
{isAdmin && branchingEnabled && (
|
||||||
<TableCell className="px-4 py-3.5">
|
<TableCell className="px-4 py-3.5">
|
||||||
@@ -372,7 +234,7 @@ export default function PurchaseInvoicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -399,6 +261,14 @@ export default function PurchaseInvoicesPage() {
|
|||||||
Pay
|
Pay
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Show invoice ${row.docNo}`}
|
||||||
|
title="Show invoice"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -424,9 +294,22 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="border-t px-4 py-4">
|
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
|
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
|
||||||
setPo(result.data)
|
setPo(result.data)
|
||||||
setEtag(result.etag)
|
setEtag(result.etag)
|
||||||
setLines(toDraftLines(result.data))
|
setLines(toDraftLines(result.data))
|
||||||
@@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{po.status === "Draft" && (
|
{po.status === "Draft" && (
|
||||||
<>
|
<>
|
||||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||||
<Send className="size-5" />
|
<Send className="size-5" />
|
||||||
{submitting ? "Submitting…" : "Submit for approval"}
|
{submitting ? "Submitting…" : "Submit for approval"}
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
|
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
{deleting ? "Deleting…" : "Delete draft"}
|
{deleting ? "Deleting…" : "Delete draft"}
|
||||||
@@ -348,11 +348,11 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{po.status === "PendingApproval" && isAdmin && (
|
{po.status === "PendingApproval" && isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
|
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}>
|
||||||
<Check className="size-5" />
|
<Check className="size-5" />
|
||||||
{approving ? "Approving…" : "Approve"}
|
{approving ? "Approving…" : "Approve"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
|
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -362,7 +362,6 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={() => setShowCancelForm(true)}
|
onClick={() => setShowCancelForm(true)}
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
||||||
@@ -381,13 +380,13 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
value={cancelReason}
|
value={cancelReason}
|
||||||
onChange={(e) => setCancelReason(e.target.value)}
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
placeholder="Reason (e.g. Duplicate order)"
|
placeholder="Reason (e.g. Duplicate order)"
|
||||||
className="h-11 w-full text-base sm:max-w-md"
|
className="h-11 max-w-md text-base"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
|
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
|
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||||
{cancelling ? "Cancelling…" : "Confirm cancellation"}
|
{cancelling ? "Cancelling…" : "Confirm cancellation"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,7 +437,7 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
{editable && (
|
{editable && (
|
||||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||||
@@ -448,148 +447,7 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{lines.map((line) => {
|
|
||||||
const errors = lineErrors[line.key] ?? {}
|
|
||||||
const item = itemFor(line.itemId)
|
|
||||||
|
|
||||||
if (!editable) {
|
|
||||||
return (
|
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">UOM</p>
|
|
||||||
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Warehouse</p>
|
|
||||||
<p className="font-medium text-foreground">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Qty / Received</p>
|
|
||||||
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Unit price / Tax</p>
|
|
||||||
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<FieldLabel className="text-sm">Item</FieldLabel>
|
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{items.map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="mt-6 shrink-0"
|
|
||||||
onClick={() => removeLine(line.key)}
|
|
||||||
aria-label="Remove line"
|
|
||||||
disabled={line.qtyReceived > 0}
|
|
||||||
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<FieldLabel className="text-sm">UOM</FieldLabel>
|
|
||||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FieldLabel className="text-sm">Warehouse</FieldLabel>
|
|
||||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
|
||||||
<SelectValue placeholder="Warehouse" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{warehouses.map((w) => (
|
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
|
||||||
{w.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<FieldLabel className="text-sm">Qty (received {line.qtyReceived})</FieldLabel>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={line.qtyReceived}
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.qty}
|
|
||||||
aria-invalid={!!errors.qty}
|
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FieldLabel className="text-sm">Unit price</FieldLabel>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.unitPrice}
|
|
||||||
aria-invalid={!!errors.unitPrice}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FieldLabel className="text-sm">Tax</FieldLabel>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.tax}
|
|
||||||
aria-invalid={!!errors.tax}
|
|
||||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
|
||||||
className="h-11 w-full text-base sm:w-32"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -716,11 +574,11 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editable && (
|
{editable && (
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
|
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
|
<Button size="lg" onClick={handleSave} disabled={saving}>
|
||||||
<Save className="size-5" />
|
<Save className="size-5" />
|
||||||
{saving ? "Saving…" : "Save changes"}
|
{saving ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react"
|
||||||
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
|
|
||||||
|
|
||||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
@@ -35,7 +34,6 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
|
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
|
||||||
*/
|
*/
|
||||||
export default function PurchaseOrderApprovalsPage() {
|
export default function PurchaseOrderApprovalsPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
|
|
||||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||||
@@ -158,14 +156,7 @@ export default function PurchaseOrderApprovalsPage() {
|
|||||||
{pos.map((po) => {
|
{pos.map((po) => {
|
||||||
const vendor = vendorsById.get(po.vendorId)
|
const vendor = vendorsById.get(po.vendorId)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={po.poId} className="align-top">
|
||||||
key={po.poId}
|
|
||||||
className="align-top cursor-pointer hover:bg-muted/40"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||||
{po.docNo}
|
{po.docNo}
|
||||||
@@ -182,7 +173,7 @@ export default function PurchaseOrderApprovalsPage() {
|
|||||||
{new Date(po.createdAt).toLocaleString()}
|
{new Date(po.createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,6 +195,14 @@ export default function PurchaseOrderApprovalsPage() {
|
|||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label="View"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { requisitionsApi } from "@/lib/api/requisitions"
|
|||||||
import { rfqsApi } from "@/lib/api/rfqs"
|
import { rfqsApi } from "@/lib/api/rfqs"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
@@ -20,9 +19,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { generateVendorCode } from "@/lib/vendor-code"
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
import { CreatePoLineInput } from "@/types/procurement"
|
import { CreatePoLineInput } from "@/types/procurement"
|
||||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||||
import { Branch } from "@/types/hrm"
|
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -69,14 +66,11 @@ function NewPurchaseOrderContent() {
|
|||||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||||
const rfqId = Number(searchParams.get("rfqId")) || null
|
const rfqId = Number(searchParams.get("rfqId")) || null
|
||||||
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
||||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
|
||||||
|
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
|
||||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
|
||||||
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
|
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -217,21 +211,6 @@ function NewPurchaseOrderContent() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [requisitionId, rfqId, rfqVendorId])
|
}, [requisitionId, rfqId, rfqVendorId])
|
||||||
|
|
||||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
|
||||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
|
||||||
useEffect(() => {
|
|
||||||
if (branchingEnabled && isAdmin) {
|
|
||||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
|
||||||
}
|
|
||||||
}, [branchingEnabled, isAdmin])
|
|
||||||
|
|
||||||
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
|
|
||||||
// the dropdown only appears when the header itself is on "All Branches", so there's no
|
|
||||||
// branch to pin to and the Admin must pick one explicitly for this PO.
|
|
||||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
|
||||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
|
||||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||||
}
|
}
|
||||||
@@ -252,10 +231,6 @@ function NewPurchaseOrderContent() {
|
|||||||
setHeaderError("Select a vendor.")
|
setHeaderError("Select a vendor.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (branchingEnabled && !effectiveBranchId) {
|
|
||||||
setHeaderError("Select a branch for this purchase order.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
setSubmitError("Add at least one line.")
|
setSubmitError("Add at least one line.")
|
||||||
return
|
return
|
||||||
@@ -293,7 +268,6 @@ function NewPurchaseOrderContent() {
|
|||||||
requisitionId: requisitionId ?? (rfqId ? undefined : null),
|
requisitionId: requisitionId ?? (rfqId ? undefined : null),
|
||||||
lines: payloadLines,
|
lines: payloadLines,
|
||||||
saveAsDraft,
|
saveAsDraft,
|
||||||
branchId: effectiveBranchId ?? 0,
|
|
||||||
})
|
})
|
||||||
toast.success(
|
toast.success(
|
||||||
"Purchase order created",
|
"Purchase order created",
|
||||||
@@ -395,30 +369,6 @@ function NewPurchaseOrderContent() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{branchingEnabled && isAdmin && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Branch</Label>
|
|
||||||
{showBranchPicker ? (
|
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue placeholder="All branches" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
|
|
||||||
{b.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
|
||||||
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{requisitionId && (
|
{requisitionId && (
|
||||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||||
)}
|
)}
|
||||||
@@ -430,9 +380,9 @@ function NewPurchaseOrderContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/products/new"
|
href="/dashboard/products/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -451,21 +401,27 @@ function NewPurchaseOrderContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lines.length > 0 && (
|
{lines.length > 0 && (
|
||||||
<>
|
<div className="overflow-x-auto">
|
||||||
{/* Mobile: stacked editable cards, one per line. */}
|
<Table className="table-fixed text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
{lines.map((line) => {
|
<TableRow>
|
||||||
const errors = lineErrors[line.key] ?? {}
|
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||||
const item = itemFor(line.itemId)
|
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||||
return (
|
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||||
<div className="flex items-start gap-2">
|
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||||
<div className="flex-1">
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
<Label className="text-sm">Item</Label>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => {
|
||||||
|
const errors = lineErrors[line.key] ?? {}
|
||||||
|
const item = itemFor(line.itemId)
|
||||||
|
return (
|
||||||
|
<TableRow key={line.key}>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{requisitionId || rfqId ? (
|
{requisitionId || rfqId ? (
|
||||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>
|
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||||
@@ -487,28 +443,13 @@ function NewPurchaseOrderContent() {
|
|||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
<Button
|
<TableCell className="px-3 py-3 align-top">
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="mt-6 shrink-0"
|
|
||||||
onClick={() => removeLine(line.key)}
|
|
||||||
aria-label="Remove line"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">UOM</Label>
|
|
||||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Label className="text-sm">Warehouse</Label>
|
|
||||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||||
<SelectValue placeholder="Warehouse" />
|
<SelectValue placeholder="Warehouse" />
|
||||||
@@ -522,144 +463,42 @@ function NewPurchaseOrderContent() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Qty</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
inputMode="decimal"
|
|
||||||
value={line.qty}
|
value={line.qty}
|
||||||
aria-invalid={!!errors.qty}
|
aria-invalid={!!errors.qty}
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</div>
|
</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Label className="text-sm">Unit price</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
inputMode="decimal"
|
|
||||||
value={line.unitPrice}
|
value={line.unitPrice}
|
||||||
aria-invalid={!!errors.unitPrice}
|
aria-invalid={!!errors.unitPrice}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
</div>
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||||
)
|
<Trash2 className="size-5" />
|
||||||
})}
|
</Button>
|
||||||
</div>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
{/* Desktop: table. */}
|
)
|
||||||
<div className="hidden overflow-x-auto md:block">
|
})}
|
||||||
<Table className="table-fixed text-base">
|
</TableBody>
|
||||||
<TableHeader>
|
</Table>
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
|
||||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
|
||||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{lines.map((line) => {
|
|
||||||
const errors = lineErrors[line.key] ?? {}
|
|
||||||
const item = itemFor(line.itemId)
|
|
||||||
return (
|
|
||||||
<TableRow key={line.key}>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
{requisitionId || rfqId ? (
|
|
||||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
|
||||||
<SelectTrigger
|
|
||||||
className="h-11! w-full text-base"
|
|
||||||
aria-invalid={!!errors.itemId}
|
|
||||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
|
||||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
|
||||||
<SelectValue placeholder="Warehouse" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(warehouses ?? []).map((w) => (
|
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
|
||||||
{w.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={line.qty}
|
|
||||||
aria-invalid={!!errors.qty}
|
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={line.unitPrice}
|
|
||||||
aria-invalid={!!errors.unitPrice}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -667,14 +506,14 @@ function NewPurchaseOrderContent() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
|
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
|
||||||
{submitting ? "Saving…" : "Save as draft"}
|
{submitting ? "Saving…" : "Save as draft"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
|
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||||
{submitting ? "Creating…" : "Create & submit"}
|
{submitting ? "Creating…" : "Create & submit"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
@@ -20,7 +19,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
@@ -30,13 +28,9 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = PurchaseOrderStatus | "All"
|
type StatusFilter = PurchaseOrderStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE = 10
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function PurchaseOrdersListPage() {
|
export default function PurchaseOrdersListPage() {
|
||||||
const router = useRouter()
|
|
||||||
const stats = useDashboardStats()
|
const stats = useDashboardStats()
|
||||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||||
@@ -49,10 +43,8 @@ export default function PurchaseOrdersListPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [submittingId, setSubmittingId] = useState<number | null>(null)
|
const [submittingId, setSubmittingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
@@ -64,21 +56,15 @@ export default function PurchaseOrdersListPage() {
|
|||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
purchaseOrdersApi
|
purchaseOrdersApi
|
||||||
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
|
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setPos(res.items)
|
setPos(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowMore() {
|
useEffect(load, [page, query, status])
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, query, status])
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -135,15 +121,12 @@ export default function PurchaseOrdersListPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||||
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).</p>
|
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
href="/dashboard/procurement/purchase-orders/approvals"
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
|
||||||
>
|
|
||||||
<Clock className="size-5" />
|
<Clock className="size-5" />
|
||||||
Approvals queue
|
Approvals queue
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New PO
|
New PO
|
||||||
</Link>
|
</Link>
|
||||||
@@ -187,21 +170,9 @@ export default function PurchaseOrdersListPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && pos === null && (
|
{!error && pos === null && (
|
||||||
<div className="overflow-hidden rounded-xl border">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="ml-4 h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -215,171 +186,45 @@ export default function PurchaseOrdersListPage() {
|
|||||||
|
|
||||||
{!error && visiblePos !== null && visiblePos.length > 0 && (
|
{!error && visiblePos !== null && visiblePos.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: stacked cards. */}
|
<Table className="text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
{visiblePos.map((po) => {
|
<TableRow>
|
||||||
const editable = isPoEditable(po.status)
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
return (
|
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||||
<div
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
key={po.poId}
|
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||||
role="link"
|
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||||
tabIndex={0}
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
onClick={(e) => {
|
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
</TableRow>
|
||||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
</TableHeader>
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
|
|
||||||
</div>
|
|
||||||
<PoStatusBadge status={po.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Grand total</p>
|
|
||||||
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Created</p>
|
|
||||||
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
{isAdmin && branchingEnabled && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Branch</p>
|
|
||||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
|
||||||
{branchName(po.branchId)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editable && (
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
|
||||||
aria-label="Edit draft"
|
|
||||||
title="Edit draft"
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Submit for approval"
|
|
||||||
title="Submit for approval"
|
|
||||||
disabled={submittingId === po.poId || deletingId === po.poId}
|
|
||||||
className="text-success hover:bg-success/10 hover:text-success"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Send className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="success"
|
|
||||||
title={`Submit ${po.docNo} for approval?`}
|
|
||||||
description="It will be locked for editing while it awaits an Admin's approval."
|
|
||||||
confirmLabel="Submit"
|
|
||||||
onConfirm={() => handleSubmit(po)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Delete draft"
|
|
||||||
title="Delete draft"
|
|
||||||
disabled={deletingId === po.poId || submittingId === po.poId}
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant="destructive"
|
|
||||||
title={`Delete draft ${po.docNo}?`}
|
|
||||||
description="This cannot be undone."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onConfirm={() => handleDelete(po)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{pagination && pos !== null && pos.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-48">Doc No</TableHead>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Grand total</TableHead>
|
|
||||||
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
<TableHead className="w-44 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visiblePos.map((po) => {
|
{visiblePos.map((po) => {
|
||||||
const editable = isPoEditable(po.status)
|
const editable = isPoEditable(po.status)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={po.poId}>
|
||||||
key={po.poId}
|
<TableCell className="px-3 py-3.5">
|
||||||
className="align-top cursor-pointer hover:bg-muted/20"
|
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
|
|
||||||
{po.docNo}
|
{po.docNo}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-3 py-3.5">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
{isAdmin && branchingEnabled && (
|
{isAdmin && branchingEnabled && (
|
||||||
<TableCell>
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
|
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||||
<TableCell data-no-row-nav>
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
aria-label="View purchase order"
|
aria-label="View"
|
||||||
title="View"
|
title="View"
|
||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
@@ -451,17 +296,30 @@ export default function PurchaseOrdersListPage() {
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center justify-between">
|
||||||
<PaginationFooter
|
<p className="text-sm text-muted-foreground">
|
||||||
pagination={pagination}
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
onPageChange={setPage}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
</p>
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -210,99 +210,49 @@ function NewPurchaseReturnContent() {
|
|||||||
{selectedGrn && (
|
{selectedGrn && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
|
||||||
|
<Table className="text-base">
|
||||||
{/* Mobile: stacked cards. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableRow>
|
||||||
{selectedGrn.lines.map((line) => {
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
const item = itemFor(line.itemId)
|
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
|
||||||
const errors = lineErrors[line.grnLineId] ?? {}
|
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||||
return (
|
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||||
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
</TableRow>
|
||||||
<label className="flex items-start gap-3">
|
</TableHeader>
|
||||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
|
<TableBody>
|
||||||
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
{selectedGrn.lines.map((line) => {
|
||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
const item = itemFor(line.itemId)
|
||||||
</span>
|
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||||
</label>
|
const errors = lineErrors[line.grnLineId] ?? {}
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableRow key={line.grnLineId}>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="text-muted-foreground">Received qty</p>
|
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
||||||
<p className="font-medium text-foreground">{line.qty}</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
<p className="text-muted-foreground">Hold status</p>
|
<TableCell className="px-3 py-3.5">
|
||||||
<HoldStatusBadge status={line.holdStatus} />
|
<HoldStatusBadge status={line.holdStatus} />
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
<div>
|
type="number"
|
||||||
<Label className="text-sm">Return qty</Label>
|
min="0"
|
||||||
<Input
|
step="any"
|
||||||
type="number"
|
value={state.qty}
|
||||||
min="0"
|
disabled={!state.selected}
|
||||||
step="any"
|
aria-invalid={!!errors.qty}
|
||||||
inputMode="decimal"
|
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||||
value={state.qty}
|
className="h-11 text-base"
|
||||||
disabled={!state.selected}
|
/>
|
||||||
aria-invalid={!!errors.qty}
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
</TableCell>
|
||||||
className="h-11 text-base"
|
</TableRow>
|
||||||
/>
|
)
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
})}
|
||||||
</div>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
|
||||||
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{selectedGrn.lines.map((line) => {
|
|
||||||
const item = itemFor(line.itemId)
|
|
||||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
|
||||||
const errors = lineErrors[line.grnLineId] ?? {}
|
|
||||||
return (
|
|
||||||
<TableRow key={line.grnLineId}>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<HoldStatusBadge status={line.holdStatus} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={state.qty}
|
|
||||||
disabled={!state.selected}
|
|
||||||
aria-invalid={!!errors.qty}
|
|
||||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -310,11 +260,11 @@ function NewPurchaseReturnContent() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||||
{submitting ? "Posting…" : "Post Return"}
|
{submitting ? "Posting…" : "Post Return"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,70 +9,31 @@ import { vendorsApi } from "@/lib/api/vendors"
|
|||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { PaginationMeta } from "@/types/common"
|
|
||||||
import { PurchaseReturnSummary } from "@/types/procurement"
|
import { PurchaseReturnSummary } from "@/types/procurement"
|
||||||
import { Vendor, Warehouse } from "@/types/master-data"
|
import { Vendor, Warehouse } from "@/types/master-data"
|
||||||
import { ReasonCode } from "@/types/stock"
|
import { ReasonCode } from "@/types/stock"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function PurchaseReturnsListPage() {
|
export default function PurchaseReturnsListPage() {
|
||||||
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
|
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||||
return () => clearTimeout(timeout)
|
.then(([r, v, w, rc]) => {
|
||||||
}, [searchInput])
|
setReturns(r.items)
|
||||||
|
|
||||||
useEffect(() => setPage(1), [query])
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
setError(null)
|
|
||||||
purchaseReturnsApi
|
|
||||||
.list({ page, pageSize, q: query || undefined })
|
|
||||||
.then((res) => {
|
|
||||||
setReturns(res.items)
|
|
||||||
setPagination(res.pagination)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, query])
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
|
||||||
.then(([v, w, rc]) => {
|
|
||||||
setVendors(v.items)
|
setVendors(v.items)
|
||||||
setWarehouses(w.items)
|
setWarehouses(w.items)
|
||||||
setReasonCodes(rc.items)
|
setReasonCodes(rc.items)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function vendorCode(id: number) {
|
function vendorCode(id: number) {
|
||||||
@@ -85,8 +46,6 @@ export default function PurchaseReturnsListPage() {
|
|||||||
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -94,42 +53,20 @@ export default function PurchaseReturnsListPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
|
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
|
||||||
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
|
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Return
|
New Return
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<Input
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
placeholder="Search doc no., vendor…"
|
|
||||||
className="h-12 w-full flex-1 basis-0 text-base"
|
|
||||||
aria-label="Search purchase returns"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && returns === null && (
|
{!error && returns === null && (
|
||||||
<div className="overflow-hidden rounded-xl border">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="ml-4 h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -137,107 +74,45 @@ export default function PurchaseReturnsListPage() {
|
|||||||
{!error && returns !== null && returns.length === 0 && (
|
{!error && returns !== null && returns.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<PackageX className="size-12 text-muted-foreground" />
|
<PackageX className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
|
<p className="text-base text-muted-foreground">No purchase returns yet.</p>
|
||||||
{!hasFilters && (
|
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Plus className="size-5" />
|
||||||
<Plus className="size-5" />
|
New Return
|
||||||
New Return
|
</Link>
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && returns !== null && returns.length > 0 && (
|
{!error && returns !== null && returns.length > 0 && (
|
||||||
<>
|
<Table className="text-base">
|
||||||
{/* Mobile: stacked cards. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{returns.map((r) => (
|
{returns.map((r) => (
|
||||||
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow key={r.returnId}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||||
<div className="min-w-0">
|
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell>
|
||||||
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
|
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
|
||||||
<p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
|
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
<div>
|
</TableRow>
|
||||||
<p className="text-muted-foreground">Warehouse</p>
|
|
||||||
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Reason</p>
|
|
||||||
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">GL Journal</p>
|
|
||||||
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Created</p>
|
|
||||||
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</TableBody>
|
||||||
{pagination && returns !== null && returns.length < pagination.totalItems && (
|
</Table>
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-48">Doc No</TableHead>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>Warehouse</TableHead>
|
|
||||||
<TableHead>Reason</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>GL Journal</TableHead>
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{returns.map((r) => (
|
|
||||||
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={pagination}
|
|
||||||
onPageChange={setPage}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ import { Plus, Trash2 } from "lucide-react"
|
|||||||
|
|
||||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateRequisitionLine } from "@/lib/validations/procurement"
|
import { validateRequisitionLine } from "@/lib/validations/procurement"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CreateReqLineInput } from "@/types/procurement"
|
import { CreateReqLineInput } from "@/types/procurement"
|
||||||
import { ItemListItem } from "@/types/master-data"
|
import { ItemListItem } from "@/types/master-data"
|
||||||
import { Branch } from "@/types/hrm"
|
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -44,7 +40,6 @@ function emptyLine(): DraftLine {
|
|||||||
|
|
||||||
export default function NewRequisitionPage() {
|
export default function NewRequisitionPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
|
||||||
|
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
@@ -52,25 +47,11 @@ export default function NewRequisitionPage() {
|
|||||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
|
||||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
|
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
|
||||||
// branch is used directly (mirrors the New GRN / New Sale pattern).
|
|
||||||
useEffect(() => {
|
|
||||||
if (branchingEnabled && isAdmin) {
|
|
||||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
|
||||||
}
|
|
||||||
}, [branchingEnabled, isAdmin])
|
|
||||||
|
|
||||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
|
||||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
|
||||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||||
}
|
}
|
||||||
@@ -86,10 +67,6 @@ export default function NewRequisitionPage() {
|
|||||||
setSubmitError("Add at least one line.")
|
setSubmitError("Add at least one line.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (branchingEnabled && !effectiveBranchId) {
|
|
||||||
setSubmitError("Select a branch for this requisition.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -110,7 +87,7 @@ export default function NewRequisitionPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
|
const requisition = await requisitionsApi.create({ lines: payloadLines })
|
||||||
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
|
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
|
||||||
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
|
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -140,30 +117,6 @@ export default function NewRequisitionPage() {
|
|||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
{branchingEnabled && isAdmin && (
|
|
||||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
|
||||||
<Label className="text-base">Branch</Label>
|
|
||||||
{showBranchPicker ? (
|
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue placeholder="All branches" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
|
|
||||||
{b.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
|
||||||
{activeBranchName ?? `Branch #${lockedBranchId}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -24,7 +23,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = RequisitionStatus | "All"
|
type StatusFilter = RequisitionStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
export default function RequisitionsListPage() {
|
export default function RequisitionsListPage() {
|
||||||
const stats = useDashboardStats()
|
const stats = useDashboardStats()
|
||||||
@@ -153,7 +152,29 @@ export default function RequisitionsListPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { productionRunsApi } from "@/lib/api/production-runs"
|
|||||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { PaginationMeta } from "@/types/common"
|
|
||||||
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -36,10 +34,6 @@ function formatCost(value: number) {
|
|||||||
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// The produced list comes back whole (no server paging), so pages are sliced client-side from
|
|
||||||
// the already-loaded lines. The summary totals still cover the whole filtered list.
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
|
|
||||||
export default function ProducedItemsPage() {
|
export default function ProducedItemsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -55,8 +49,6 @@ export default function ProducedItemsPage() {
|
|||||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
@@ -68,11 +60,7 @@ export default function ProducedItemsPage() {
|
|||||||
warehouseId: warehouseId ?? undefined,
|
warehouseId: warehouseId ?? undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(setData)
|
||||||
setData(res)
|
|
||||||
// A new filter is a new list — start back at the first page.
|
|
||||||
setPage(1)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
||||||
setLoadError(errorMessage(err))
|
setLoadError(errorMessage(err))
|
||||||
@@ -92,37 +80,26 @@ export default function ProducedItemsPage() {
|
|||||||
|
|
||||||
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
||||||
|
|
||||||
const totalItems = data?.items.length ?? 0
|
|
||||||
const pagination: PaginationMeta = {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalItems,
|
|
||||||
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
|
|
||||||
}
|
|
||||||
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
|
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
||||||
<p className="text-sm text-muted-foreground sm:text-base">
|
<p className="text-base text-muted-foreground">
|
||||||
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-up on phones (date range side by side, then template/warehouse), status full width —
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
five stacked labelled controls would push the results well below the fold. */}
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
<Label className="text-base">From</Label>
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
||||||
<Label className="text-sm sm:text-base">From</Label>
|
|
||||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-sm sm:text-base">To</Label>
|
<Label className="text-base">To</Label>
|
||||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-sm sm:text-base">Template</Label>
|
<Label className="text-base">Template</Label>
|
||||||
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
<SelectValue placeholder="All templates" />
|
<SelectValue placeholder="All templates" />
|
||||||
@@ -134,8 +111,8 @@ export default function ProducedItemsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-sm sm:text-base">Warehouse</Label>
|
<Label className="text-base">Warehouse</Label>
|
||||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
<SelectValue placeholder="All warehouses" />
|
<SelectValue placeholder="All warehouses" />
|
||||||
@@ -147,8 +124,8 @@ export default function ProducedItemsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-sm sm:text-base">Status</Label>
|
<Label className="text-base">Status</Label>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -198,148 +175,67 @@ export default function ProducedItemsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
{/* Mobile: totals first (the figure people come here for), then one card per line. */}
|
<Table className="text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
</p>
|
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
<p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
|
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||||
<div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||||
<div>
|
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||||
<p className="text-muted-foreground">Produced</p>
|
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
|
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||||
</div>
|
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||||
<div>
|
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||||
<p className="text-muted-foreground">Scrapped</p>
|
</TableRow>
|
||||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
|
</TableHeader>
|
||||||
</div>
|
<TableBody>
|
||||||
<div>
|
{data.items.map((line) => (
|
||||||
<p className="text-muted-foreground">Good</p>
|
<TableRow
|
||||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
|
key={line.runId}
|
||||||
</div>
|
className="cursor-pointer"
|
||||||
</div>
|
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||||
</div>
|
>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||||
{pageItems.map((line) => (
|
<TableCell className="px-3 py-3.5">
|
||||||
<button
|
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||||
key={line.runId}
|
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||||
type="button"
|
</Badge>
|
||||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold text-foreground">{line.docNo}</p>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
|
||||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Produced</p>
|
|
||||||
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Scrapped</p>
|
|
||||||
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Good</p>
|
|
||||||
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Unit cost</p>
|
|
||||||
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Cost of production</p>
|
|
||||||
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
|
|
||||||
{line.templateName} · {line.warehouseName}
|
|
||||||
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pageItems.map((line) => (
|
|
||||||
<TableRow
|
|
||||||
key={line.runId}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
|
||||||
>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
|
||||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
|
||||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5" />
|
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
|
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5" />
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableFooter>
|
))}
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
<PaginationFooter
|
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||||
pagination={pagination}
|
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||||
onPageChange={setPage}
|
</TableCell>
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||||
/>
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||||
</>
|
<TableCell className="px-3 py-3.5" />
|
||||||
|
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||||
|
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5" />
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function RunActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
|
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
|
||||||
<Undo2 className="size-4" />
|
<Undo2 className="size-4" />
|
||||||
Return leftover
|
Return leftover
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
|||||||
import { ReasonCode } from "@/types/stock"
|
import { ReasonCode } from "@/types/stock"
|
||||||
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
|
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
|
||||||
import {
|
import {
|
||||||
RunStageCardBody,
|
|
||||||
RunHeaderNodeComponent,
|
RunHeaderNodeComponent,
|
||||||
RunStageNodeComponent,
|
RunStageNodeComponent,
|
||||||
type RunHeaderData,
|
type RunHeaderData,
|
||||||
@@ -51,75 +50,7 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
|
|||||||
*/
|
*/
|
||||||
const HEADER_GAP_X = 240
|
const HEADER_GAP_X = 240
|
||||||
|
|
||||||
/** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
|
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
|
||||||
function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
|
|
||||||
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
|
|
||||||
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
|
|
||||||
const actionable = ["InProgress", "Done", "Ready"] as const
|
|
||||||
const activeId =
|
|
||||||
run.status === "InProgress"
|
|
||||||
? actionable.reduce<number | null>(
|
|
||||||
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return new Map(
|
|
||||||
run.stages.map((stage) => {
|
|
||||||
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
|
|
||||||
return [
|
|
||||||
stage.runStageId,
|
|
||||||
{
|
|
||||||
name: stage.name,
|
|
||||||
roleLabel: stage.roleLabel,
|
|
||||||
state: stage.status,
|
|
||||||
isTerminal: stage.isTerminal,
|
|
||||||
isEntry: stage.isEntry,
|
|
||||||
estimatedMinutes: stage.estimatedMinutes,
|
|
||||||
actualMinutes: stage.actualMinutes,
|
|
||||||
actualStartAt: stage.actualStartAt,
|
|
||||||
intake:
|
|
||||||
upstream.length === 0
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
|
|
||||||
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
|
|
||||||
},
|
|
||||||
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
|
|
||||||
isActive: stage.runStageId === activeId,
|
|
||||||
} satisfies RunStageData,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
|
|
||||||
* (left to right, then top to bottom) so parallel branches read the way they were drawn.
|
|
||||||
*/
|
|
||||||
function flowOrder(run: ProductionRunGraph): RunStage[] {
|
|
||||||
const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
|
|
||||||
const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
|
|
||||||
for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
|
|
||||||
|
|
||||||
const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
|
|
||||||
const ordered: RunStage[] = []
|
|
||||||
while (ready.length > 0) {
|
|
||||||
ready.sort(byPosition)
|
|
||||||
const stage = ready.shift()!
|
|
||||||
ordered.push(stage)
|
|
||||||
for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
|
|
||||||
const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
|
|
||||||
inDegree.set(e.childRunStageId, next)
|
|
||||||
if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
|
|
||||||
const seen = new Set(ordered.map((s) => s.runStageId))
|
|
||||||
return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>): { nodes: Node[]; edges: Edge[] } {
|
|
||||||
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
|
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
|
||||||
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
|
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
|
||||||
|
|
||||||
@@ -133,12 +64,42 @@ function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
|
||||||
|
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
|
||||||
|
const actionable = ["InProgress", "Done", "Ready"] as const
|
||||||
|
const activeId =
|
||||||
|
run.status === "InProgress"
|
||||||
|
? actionable.reduce<number | null>(
|
||||||
|
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
for (const stage of run.stages) {
|
for (const stage of run.stages) {
|
||||||
|
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: String(stage.runStageId),
|
id: String(stage.runStageId),
|
||||||
type: "runStage",
|
type: "runStage",
|
||||||
position: { x: stage.posX, y: stage.posY },
|
position: { x: stage.posX, y: stage.posY },
|
||||||
data: stageData.get(stage.runStageId)!,
|
data: {
|
||||||
|
name: stage.name,
|
||||||
|
roleLabel: stage.roleLabel,
|
||||||
|
state: stage.status,
|
||||||
|
isTerminal: stage.isTerminal,
|
||||||
|
isEntry: stage.isEntry,
|
||||||
|
estimatedMinutes: stage.estimatedMinutes,
|
||||||
|
actualMinutes: stage.actualMinutes,
|
||||||
|
actualStartAt: stage.actualStartAt,
|
||||||
|
intake:
|
||||||
|
upstream.length === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
|
||||||
|
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
|
||||||
|
},
|
||||||
|
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
|
||||||
|
isActive: stage.runStageId === activeId,
|
||||||
|
} satisfies RunStageData,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,12 +173,10 @@ export default function ProductionRunDetailPage() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
|
|
||||||
const { nodes, edges } = useMemo(
|
const { nodes, edges } = useMemo(
|
||||||
() => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
|
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
|
||||||
[run, stageData]
|
[run]
|
||||||
)
|
)
|
||||||
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
|
|
||||||
|
|
||||||
const stageSummary: StageSummary = useMemo(() => {
|
const stageSummary: StageSummary = useMemo(() => {
|
||||||
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
|
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
|
||||||
@@ -254,9 +213,9 @@ export default function ProductionRunDetailPage() {
|
|||||||
if (!run) {
|
if (!run) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Skeleton className="h-10 w-full max-w-64" />
|
<Skeleton className="h-10 w-64" />
|
||||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
|
<Skeleton className="h-[45vh] w-full rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -333,38 +292,9 @@ export default function ProductionRunDetailPage() {
|
|||||||
<StageStatusLegend />
|
<StageStatusLegend />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
|
||||||
<span className="md:hidden">Tap a stage to open it.</span>
|
|
||||||
<span className="hidden md:inline">Click a stage to open it.</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Phones get the stages as a list in flow order — the operator on the floor needs to
|
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||||
reach "their" stage in one tap, not pan and pinch around a canvas. */}
|
|
||||||
<ol className="flex flex-col gap-3 md:hidden">
|
|
||||||
{orderedStages.map((stage, i) => {
|
|
||||||
const data = stageData.get(stage.runStageId)!
|
|
||||||
return (
|
|
||||||
<li key={stage.runStageId} className="flex gap-3">
|
|
||||||
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStageId(stage.runStageId)}
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
|
|
||||||
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
|
|
||||||
)}
|
|
||||||
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
|
|
||||||
>
|
|
||||||
<RunStageCardBody data={data} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
|
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
|||||||
@@ -19,16 +19,12 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
|
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 25
|
||||||
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
|
|
||||||
// from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
type StatusFilter = ProductionRunStatus | "All"
|
type StatusFilter = ProductionRunStatus | "All"
|
||||||
|
|
||||||
@@ -60,8 +56,6 @@ export default function ProductionRunsPage() {
|
|||||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
@@ -90,7 +84,7 @@ export default function ProductionRunsPage() {
|
|||||||
productionRunsApi
|
productionRunsApi
|
||||||
.list({
|
.list({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize: PAGE_SIZE,
|
||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
templateId: templateId ?? undefined,
|
templateId: templateId ?? undefined,
|
||||||
@@ -104,16 +98,10 @@ export default function ProductionRunsPage() {
|
|||||||
setRuns([])
|
setRuns([])
|
||||||
setLoadError(errorMessage(err))
|
setLoadError(errorMessage(err))
|
||||||
})
|
})
|
||||||
.finally(() => setShowMoreLoading(false))
|
}, [page, query, status, templateId, warehouseId])
|
||||||
}, [page, pageSize, query, status, templateId, warehouseId])
|
|
||||||
|
|
||||||
useEffect(load, [load])
|
useEffect(load, [load])
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
|
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
|
||||||
// Inactive template that still has historical runs; the start dialog narrows to Active
|
// Inactive template that still has historical runs; the start dialog narrows to Active
|
||||||
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
|
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
|
||||||
@@ -183,11 +171,11 @@ export default function ProductionRunsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
|
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
|
||||||
<p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
|
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
|
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>Start a run</DialogTitle>
|
<DialogTitle>Start a run</DialogTitle>
|
||||||
@@ -277,59 +265,54 @@ export default function ProductionRunsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search full width, then the three filters as a compact grid on phones (four stacked
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
|
<div className="relative flex-1 basis-0">
|
||||||
lifts the selects into the outer flex row. */}
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
||||||
<div className="relative lg:flex-1 lg:basis-0">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Search doc no…"
|
placeholder="Search doc no…"
|
||||||
className="h-12 w-full pl-11 text-base sm:h-14"
|
className="h-14 w-full pl-11 text-base"
|
||||||
aria-label="Search runs"
|
aria-label="Search runs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
|
<Select<number | null>
|
||||||
<Select<number | null>
|
value={templateId}
|
||||||
value={templateId}
|
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
|
||||||
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
|
>
|
||||||
>
|
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||||
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
|
<SelectValue placeholder="All templates" />
|
||||||
<SelectValue placeholder="All templates" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{templates.map((t) => (
|
||||||
{templates.map((t) => (
|
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<Select<number | null>
|
||||||
<Select<number | null>
|
value={warehouseId}
|
||||||
value={warehouseId}
|
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
|
||||||
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
|
>
|
||||||
>
|
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||||
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
|
<SelectValue placeholder="All warehouses" />
|
||||||
<SelectValue placeholder="All warehouses" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{warehouses.map((w) => (
|
||||||
{warehouses.map((w) => (
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||||
<SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
|
<SelectValue />
|
||||||
<SelectValue />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
@@ -442,15 +425,20 @@ export default function ProductionRunsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && runs.length < pagination.totalItems && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
|
<div className="flex items-center justify-between">
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
<p className="text-base text-muted-foreground">
|
||||||
</Button>
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
)}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
{pagination && (
|
<div className="flex gap-2">
|
||||||
<div className="hidden md:block">
|
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={pagination.page >= pagination.totalPages}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -233,9 +233,7 @@ export function StageEditorPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||||
{/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
|
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||||
dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
|
|
||||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Stage editor</DialogTitle>
|
<DialogTitle>Stage editor</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -303,7 +301,7 @@ export function StageEditorPanel({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button type="button" onClick={() => removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -406,7 +404,7 @@ export function StageEditorPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button type="button" onClick={() => removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -464,7 +462,7 @@ export function StageEditorPanel({
|
|||||||
className="h-8 flex-1 text-sm"
|
className="h-8 flex-1 text-sm"
|
||||||
/>
|
/>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button type="button" onClick={() => removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,15 +24,14 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
|
|||||||
disconnected && "opacity-40"
|
disconnected && "opacity-40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Handles are finger-sized below md: dragging a connection starts from them. */}
|
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||||
<Handle type="target" position={Position.Left} className="bg-primary! size-4! md:size-2.5!" />
|
|
||||||
|
|
||||||
{selected && data.onDelete && (
|
{selected && data.onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete stage"
|
aria-label="Delete stage"
|
||||||
title="Delete stage"
|
title="Delete stage"
|
||||||
className="nodrag absolute -top-3 -right-3 flex size-7 items-center md:-top-2.5 md:-right-2.5 md:size-5 justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
|
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
data.onDelete?.()
|
data.onDelete?.()
|
||||||
@@ -61,7 +60,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
|
|||||||
|
|
||||||
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
|
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="bg-primary! size-4! md:size-2.5!" />
|
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
|
|||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Skeleton className="h-10 w-full max-w-72" />
|
<Skeleton className="h-10 w-72" />
|
||||||
<Skeleton className="h-[60vh] w-full rounded-2xl" />
|
<Skeleton className="h-[60vh] w-full rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -661,15 +661,12 @@ function TemplateBuilderContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
|
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
||||||
// Only from lg up does the builder fill the viewport with the canvas taking the remainder —
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
// on a short screen the fields and issue list would otherwise squeeze it to nothing.
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
|
<div>
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
|
|
||||||
{graph ? (
|
{graph ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -686,17 +683,16 @@ function TemplateBuilderContent() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground sm:text-base">
|
<p className="text-base text-muted-foreground">
|
||||||
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
|
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
|
||||||
{edges.length === 1 ? "" : "s"}
|
{edges.length === 1 ? "" : "s"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" className="col-span-2 sm:col-span-1" onClick={addStage}>
|
<Button type="button" variant="outline" onClick={addStage}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
Add Stage
|
Add Stage
|
||||||
</Button>
|
</Button>
|
||||||
@@ -714,7 +710,6 @@ function TemplateBuilderContent() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={graph.status === "Active" ? "destructive" : "success"}
|
variant={graph.status === "Active" ? "destructive" : "success"}
|
||||||
className={cn(locked && "col-span-2 sm:col-span-1")}
|
|
||||||
onClick={handleToggleStatus}
|
onClick={handleToggleStatus}
|
||||||
disabled={togglingStatus}
|
disabled={togglingStatus}
|
||||||
>
|
>
|
||||||
@@ -722,7 +717,7 @@ function TemplateBuilderContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<Button type="button" className={cn(!graph && "col-span-2 sm:col-span-1")} onClick={handleSave} disabled={saving || conflict}>
|
<Button type="button" onClick={handleSave} disabled={saving || conflict}>
|
||||||
<Save className="size-5" />
|
<Save className="size-5" />
|
||||||
{saving ? "Saving…" : isNew ? "Create" : "Save"}
|
{saving ? "Saving…" : isNew ? "Create" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -730,7 +725,7 @@ function TemplateBuilderContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-[10rem_1fr_1fr]">
|
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
|
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
|
||||||
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
|
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
|
||||||
@@ -739,7 +734,7 @@ function TemplateBuilderContent() {
|
|||||||
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
||||||
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
|
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field className="sm:col-span-2 lg:col-span-1">
|
<Field>
|
||||||
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
|
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="tpl-description"
|
id="tpl-description"
|
||||||
@@ -791,7 +786,7 @@ function TemplateBuilderContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-[65dvh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 lg:h-auto lg:min-h-0 lg:flex-1">
|
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={displayNodes}
|
nodes={displayNodes}
|
||||||
@@ -811,8 +806,7 @@ function TemplateBuilderContent() {
|
|||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls showInteractive={!locked} />
|
<Controls showInteractive={!locked} />
|
||||||
{/* Covers too much of a phone-sized canvas to be worth it. */}
|
<MiniMap pannable zoomable />
|
||||||
<MiniMap pannable zoomable className="hidden! md:block!" />
|
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
|
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
|
||||||
import "@xyflow/react/dist/style.css"
|
import "@xyflow/react/dist/style.css"
|
||||||
@@ -10,7 +9,6 @@ import { LayoutTemplate, Plus, Search } from "lucide-react"
|
|||||||
|
|
||||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
|
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
@@ -24,16 +22,12 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
type StatusFilter = TemplateStatus | "All"
|
type StatusFilter = TemplateStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 25
|
||||||
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
|
|
||||||
// from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
|
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
|
||||||
|
|
||||||
@@ -97,8 +91,6 @@ export default function ProductionTemplatesPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -126,7 +118,7 @@ export default function ProductionTemplatesPage() {
|
|||||||
productionTemplatesApi
|
productionTemplatesApi
|
||||||
.list({
|
.list({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize: PAGE_SIZE,
|
||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
})
|
})
|
||||||
@@ -138,16 +130,10 @@ export default function ProductionTemplatesPage() {
|
|||||||
setTemplates([])
|
setTemplates([])
|
||||||
setLoadError(errorMessage(err))
|
setLoadError(errorMessage(err))
|
||||||
})
|
})
|
||||||
.finally(() => setShowMoreLoading(false))
|
}, [page, query, status])
|
||||||
}, [page, pageSize, query, status])
|
|
||||||
|
|
||||||
useEffect(load, [load])
|
useEffect(load, [load])
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
|
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
|
||||||
|
|
||||||
@@ -187,11 +173,11 @@ export default function ProductionTemplatesPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
|
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
|
||||||
<p className="text-sm text-muted-foreground sm:text-base">Every production line, stage by stage. Tap a line to open its builder.</p>
|
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
|
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New template</DialogTitle>
|
<DialogTitle>New template</DialogTitle>
|
||||||
@@ -266,7 +252,7 @@ export default function ProductionTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{templates === null ? (
|
{templates === null ? (
|
||||||
<Skeleton className="h-64 w-full rounded-2xl md:h-[70vh] md:min-h-105" />
|
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" />
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<LayoutTemplate className="size-12 text-muted-foreground" />
|
<LayoutTemplate className="size-12 text-muted-foreground" />
|
||||||
@@ -276,62 +262,7 @@ export default function ProductionTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
|
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||||
narrow touch screen, so each line reads top to bottom instead. */}
|
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{templates.map((t) => (
|
|
||||||
<Link
|
|
||||||
key={t.templateId}
|
|
||||||
href={`/dashboard/production/templates/${t.templateId}`}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
|
|
||||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
|
||||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.status}
|
|
||||||
</span>
|
|
||||||
{t.activeRunCount > 0 && (
|
|
||||||
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
|
||||||
{t.activeRunCount} in progress
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{t.stageNames.length > 0 ? (
|
|
||||||
<ol className="flex flex-col gap-1.5 border-t pt-3">
|
|
||||||
{t.stageNames.map((stageName, i) => (
|
|
||||||
<li key={i} className="flex items-center gap-2.5 text-sm">
|
|
||||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-foreground">{stageName}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
) : (
|
|
||||||
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && templates.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
|
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -355,9 +286,24 @@ export default function ProductionTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center justify-between">
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
<p className="text-base text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,69 +1,36 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
import { AlertTriangle, Save } from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { stockApi } from "@/lib/api/stock"
|
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateItemForm } from "@/lib/validations/master-data"
|
import { validateItemForm } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||||
import { LedgerEntry } from "@/types/stock"
|
|
||||||
|
|
||||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||||
const NO_TAX_RATE = "__none__"
|
const NO_TAX_RATE = "__none__"
|
||||||
/** Sale-channel document-type prefixes as stored on the ledger (Domain/DocumentTypes.cs). */
|
|
||||||
const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"])
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import LineChart from "@/components/ui/line-chart"
|
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
|
||||||
|
|
||||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
|
||||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
|
||||||
if (sourceDocType === "SI") return `/dashboard/sales/invoices/${sourceDocId}`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function docLabel(sourceDocType: string, sourceDocId: number): string {
|
|
||||||
const names: Record<string, string> = { GRN: "GRN", SI: "Invoice", SSL: "Slip", BND: "Bundle Sale", ADJ: "Adjustment", TRF: "Transfer", PRET: "Return" }
|
|
||||||
return `${names[sourceDocType] ?? sourceDocType} #${sourceDocId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small tinted stat chip for the profile header — value + label + icon. */
|
|
||||||
function StatChip({ icon: Icon, label, value }: { icon: typeof Package; label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Icon className="size-4.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ItemDetailPage() {
|
export default function ItemDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
const itemId = Number(params.id)
|
const itemId = Number(params.id)
|
||||||
|
|
||||||
const [item, setItem] = useState<Item | null>(null)
|
const [item, setItem] = useState<Item | null>(null)
|
||||||
@@ -73,10 +40,6 @@ export default function ItemDetailPage() {
|
|||||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Profile activity: full recent movement history for this item, across every warehouse.
|
|
||||||
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
|
|
||||||
const [stockValue, setStockValue] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Basic info form
|
// Basic info form
|
||||||
const [sku, setSku] = useState("")
|
const [sku, setSku] = useState("")
|
||||||
const [barcode, setBarcode] = useState("")
|
const [barcode, setBarcode] = useState("")
|
||||||
@@ -114,7 +77,6 @@ export default function ItemDetailPage() {
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
|
|
||||||
function applyItem(data: Item) {
|
function applyItem(data: Item) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
@@ -165,97 +127,6 @@ export default function ItemDetailPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [itemId])
|
}, [itemId])
|
||||||
|
|
||||||
// Recent activity + stock value: independent of the edit form above, loaded once the
|
|
||||||
// item id is known. A large page size is fine here — one item's lifetime history.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Number.isFinite(itemId)) return
|
|
||||||
stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([]))
|
|
||||||
stockApi
|
|
||||||
.onHandList({ itemId, pageSize: 200 })
|
|
||||||
.then((res) => res.items.filter((r) => r.onHand > 0))
|
|
||||||
.then((rows) =>
|
|
||||||
Promise.all(rows.map((r) => stockApi.valuation(itemId, r.warehouseId).catch(() => null)))
|
|
||||||
)
|
|
||||||
.then((valuations) => setStockValue(valuations.reduce((sum, v) => sum + (v?.totalValue ?? 0), 0)))
|
|
||||||
.catch(() => setStockValue(null))
|
|
||||||
}, [itemId])
|
|
||||||
|
|
||||||
const sortedLedger = useMemo(
|
|
||||||
() => (ledger ?? []).slice().sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
||||||
[ledger]
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Cumulative on-hand qty over time, summed across every warehouse — "total stock variation". */
|
|
||||||
const stockTrend = useMemo(() => {
|
|
||||||
let running = 0
|
|
||||||
const points: { date: string; qty: number }[] = []
|
|
||||||
for (const row of sortedLedger) {
|
|
||||||
running += row.direction === "In" ? row.qtyBase : -row.qtyBase
|
|
||||||
points.push({ date: row.createdAt, qty: running })
|
|
||||||
}
|
|
||||||
// Running totals are computed over the full history above so they stay accurate —
|
|
||||||
// only the most recent 7 points are kept for display.
|
|
||||||
return points.slice(-7)
|
|
||||||
}, [sortedLedger])
|
|
||||||
|
|
||||||
/** Qty in vs qty out per month, last 5 months with activity. */
|
|
||||||
const monthlyActivity = useMemo(() => {
|
|
||||||
const byMonth = new Map<string, { in: number; out: number }>()
|
|
||||||
for (const row of sortedLedger) {
|
|
||||||
const key = row.createdAt.slice(0, 7) // YYYY-MM
|
|
||||||
const bucket = byMonth.get(key) ?? { in: 0, out: 0 }
|
|
||||||
if (row.direction === "In") bucket.in += row.qtyBase
|
|
||||||
else bucket.out += row.qtyBase
|
|
||||||
byMonth.set(key, bucket)
|
|
||||||
}
|
|
||||||
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
|
|
||||||
}, [sortedLedger])
|
|
||||||
|
|
||||||
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
|
|
||||||
// blessed way to do it once (rather than on every render) is a one-time init function.
|
|
||||||
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
|
|
||||||
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(max-width: 639px)")
|
|
||||||
setIsMobile(mql.matches)
|
|
||||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
||||||
const salesQty30d = useMemo(
|
|
||||||
() =>
|
|
||||||
sortedLedger
|
|
||||||
.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType) && new Date(r.createdAt).getTime() >= cutoff30)
|
|
||||||
.reduce((sum, r) => sum + r.qtyBase, 0),
|
|
||||||
[sortedLedger, cutoff30]
|
|
||||||
)
|
|
||||||
const recentSales = useMemo(
|
|
||||||
() => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(),
|
|
||||||
[sortedLedger]
|
|
||||||
)
|
|
||||||
const recentGrns = useMemo(
|
|
||||||
() => sortedLedger.filter((r) => r.sourceDocType === "GRN").slice(-8).reverse(),
|
|
||||||
[sortedLedger]
|
|
||||||
)
|
|
||||||
const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0
|
|
||||||
|
|
||||||
/** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved
|
|
||||||
* edits left over from a previous cancelled session. */
|
|
||||||
function openEdit() {
|
|
||||||
if (item) applyItem(item)
|
|
||||||
setErrors({})
|
|
||||||
setSaveError(null)
|
|
||||||
setEditOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEdit() {
|
|
||||||
if (item) applyItem(item)
|
|
||||||
setEditOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!item || !etag) return
|
if (!item || !etag) return
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
@@ -281,7 +152,6 @@ export default function ItemDetailPage() {
|
|||||||
)
|
)
|
||||||
applyItem(result.data)
|
applyItem(result.data)
|
||||||
setEtag(result.etag)
|
setEtag(result.etag)
|
||||||
setEditOpen(false)
|
|
||||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as { code?: string })?.code
|
const code = (err as { code?: string })?.code
|
||||||
@@ -289,7 +159,6 @@ export default function ItemDetailPage() {
|
|||||||
setConflict(true)
|
setConflict(true)
|
||||||
setSaveError(errorMessage(err))
|
setSaveError(errorMessage(err))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
setEditOpen(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fe = fieldErrors(err)
|
const fe = fieldErrors(err)
|
||||||
@@ -343,19 +212,8 @@ export default function ItemDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Link
|
|
||||||
href="/dashboard/products"
|
|
||||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Back to Items
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
|
||||||
<Package className="size-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||||
@@ -370,26 +228,9 @@ export default function ItemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
|
||||||
<Button size="lg" variant="outline" onClick={openEdit}>
|
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
<Pencil className="size-4" />
|
</Button>
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
|
|
||||||
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
|
|
||||||
<StatChip
|
|
||||||
icon={BadgeDollarSign}
|
|
||||||
label="Stock value"
|
|
||||||
value={stockValue === null ? "…" : `LKR ${stockValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
|
|
||||||
/>
|
|
||||||
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
|
|
||||||
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{conflict && (
|
{conflict && (
|
||||||
@@ -408,212 +249,8 @@ export default function ItemDetailPage() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
<h2 className="text-base font-semibold text-foreground">Basic info</h2>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="size-4.5 text-primary" />
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Total stock variation</h2>
|
|
||||||
</div>
|
|
||||||
{ledger === null ? (
|
|
||||||
<Skeleton className="h-56 w-full" />
|
|
||||||
) : stockTrend.length > 1 ? (
|
|
||||||
<LineChart
|
|
||||||
labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
|
|
||||||
datasets={[
|
|
||||||
{
|
|
||||||
label: `On hand (${uomName(item.baseUomId)})`,
|
|
||||||
data: stockTrend.map((p) => p.qty),
|
|
||||||
borderColor: "rgba(225,29,72,1)",
|
|
||||||
backgroundColor: "rgba(225,29,72,0.15)",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShoppingCart className="size-4.5 text-primary" />
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Monthly purchases vs sales</h2>
|
|
||||||
</div>
|
|
||||||
{ledger === null ? (
|
|
||||||
<Skeleton className="h-56 w-full" />
|
|
||||||
) : monthlyActivity.length > 0 ? (
|
|
||||||
<BarChart
|
|
||||||
// Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
|
|
||||||
// activity, Chart.js would otherwise center the lone category across the full
|
|
||||||
// width. Trailing blank slots keep the axis/gridlines spanning the full card
|
|
||||||
// while the real bars sit at the start. Skipped on mobile — there the bars
|
|
||||||
// should stretch to fill the narrower card instead.
|
|
||||||
labels={[
|
|
||||||
...monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
|
|
||||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill("")),
|
|
||||||
]}
|
|
||||||
datasets={[
|
|
||||||
{
|
|
||||||
label: "Purchased",
|
|
||||||
data: [
|
|
||||||
...monthlyActivity.map(([, v]) => v.in),
|
|
||||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
|
|
||||||
],
|
|
||||||
backgroundColor: "rgba(34,197,94,0.6)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Sold",
|
|
||||||
data: [
|
|
||||||
...monthlyActivity.map(([, v]) => v.out),
|
|
||||||
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
|
|
||||||
],
|
|
||||||
backgroundColor: "rgba(239,68,68,0.6)",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
barPercentage={0.6}
|
|
||||||
categoryPercentage={0.6}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Recent sales</h2>
|
|
||||||
{ledger === null ? (
|
|
||||||
<Skeleton className="h-40 w-full" />
|
|
||||||
) : recentSales.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Mobile: stacked cards. */}
|
|
||||||
<div className="flex flex-col gap-2 md:hidden">
|
|
||||||
{recentSales.map((row) => {
|
|
||||||
const href = docLink(row.sourceDocType, row.sourceDocId)
|
|
||||||
return (
|
|
||||||
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
{href ? (
|
|
||||||
<Link href={href} className="truncate font-medium text-primary hover:underline">
|
|
||||||
{docLabel(row.sourceDocType, row.sourceDocId)}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="truncate font-medium text-foreground">{docLabel(row.sourceDocType, row.sourceDocId)}</span>
|
|
||||||
)}
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
|
||||||
<span>Qty {row.qtyBase.toLocaleString()}</span>
|
|
||||||
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{recentSales.map((row) => {
|
|
||||||
const href = docLink(row.sourceDocType, row.sourceDocId)
|
|
||||||
return (
|
|
||||||
<TableRow key={row.ledgerId}>
|
|
||||||
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">
|
|
||||||
{href ? (
|
|
||||||
<Link href={href} className="font-medium text-primary hover:underline">
|
|
||||||
{docLabel(row.sourceDocType, row.sourceDocId)}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
docLabel(row.sourceDocType, row.sourceDocId)
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Recent GRNs (purchases)</h2>
|
|
||||||
{ledger === null ? (
|
|
||||||
<Skeleton className="h-40 w-full" />
|
|
||||||
) : recentGrns.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Mobile: stacked cards. */}
|
|
||||||
<div className="flex flex-col gap-2 md:hidden">
|
|
||||||
{recentGrns.map((row) => (
|
|
||||||
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="truncate font-medium text-primary hover:underline">
|
|
||||||
{docLabel(row.sourceDocType, row.sourceDocId)}
|
|
||||||
</Link>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
|
||||||
<span>Qty {row.qtyBase.toLocaleString()}</span>
|
|
||||||
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-sm">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
|
|
||||||
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{recentGrns.map((row) => (
|
|
||||||
<TableRow key={row.ledgerId}>
|
|
||||||
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">
|
|
||||||
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="font-medium text-primary hover:underline">
|
|
||||||
{docLabel(row.sourceDocType, row.sourceDocId)}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit {item.sku}</DialogTitle>
|
|
||||||
<DialogDescription>Update this item's basic info, pricing and classification.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">SKU</Label>
|
<Label className="text-base">SKU</Label>
|
||||||
@@ -828,17 +465,16 @@ export default function ItemDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}>
|
||||||
Cancel
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||||
<Save className="size-5" />
|
<Save className="size-5" />
|
||||||
{saving ? "Saving…" : "Save changes"}
|
{saving ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
|
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
|
||||||
|
|||||||
@@ -1,201 +1,223 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import Link from "next/link"
|
||||||
import { Plus, Ruler, Tag } from "lucide-react"
|
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||||
|
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { validateBrandName } from "@/lib/validations/master-data"
|
||||||
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Brand, Uom } from "@/types/master-data"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
type Tab = "brand" | "uom"
|
type SortOrder = "asc" | "desc"
|
||||||
|
type SortKey = "brandId" | "name" | "status" | "createdAt"
|
||||||
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
// Large enough to show "all brands" in one grid — the chip layout replaces the
|
const PAGE_SIZE = 5
|
||||||
// old table's pagination, so we just ask for everything in one page.
|
|
||||||
const ALL_ITEMS_PAGE_SIZE = 500
|
|
||||||
|
|
||||||
export default function BrandsPage() {
|
export default function BrandsPage() {
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<BrandsPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrandsPageContent() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
|
||||||
const [tab, setTab] = useState<Tab>(initialTab)
|
|
||||||
|
|
||||||
function selectTab(next: Tab) {
|
|
||||||
setTab(next)
|
|
||||||
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
|
|
||||||
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectTab("brand")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Brand
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectTab("uom")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
UOM
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrandPanel() {
|
|
||||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState("")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||||
|
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Brand | null>(null)
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
useEffect(() => {
|
||||||
const [editing, setEditing] = useState<Brand | null>(null)
|
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||||
const [editName, setEditName] = useState("")
|
return () => clearTimeout(timeout)
|
||||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
}, [searchInput])
|
||||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [search, status])
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
brandsApi
|
brandsApi
|
||||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||||
.then((res) => setBrands(res.items))
|
.then((res) => {
|
||||||
|
setBrands(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [search, status, page])
|
||||||
|
|
||||||
async function handleAdd() {
|
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||||
|
|
||||||
|
const sortedBrands = brands
|
||||||
|
? [...brands].sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
sortKey === "brandId"
|
||||||
|
? a.brandId - b.brandId
|
||||||
|
: sortKey === "createdAt"
|
||||||
|
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
: a[sortKey].localeCompare(b[sortKey])
|
||||||
|
return sortOrder === "asc" ? cmp : -cmp
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (key === sortKey) {
|
||||||
|
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortOrder("asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditing(null)
|
||||||
|
setName("")
|
||||||
|
setErrors({})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(brand: Brand) {
|
||||||
|
setEditing(brand)
|
||||||
|
setName(brand.name)
|
||||||
|
setErrors({})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
const nextErrors = validateBrandName(name)
|
const nextErrors = validateBrandName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await brandsApi.create({ name })
|
let result
|
||||||
toast.success("Brand created", result.data.name)
|
if (editing) {
|
||||||
|
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||||
|
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
||||||
|
const current = await brandsApi.get(editing.brandId)
|
||||||
|
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
|
||||||
|
} else {
|
||||||
|
result = await brandsApi.create({ name })
|
||||||
|
}
|
||||||
|
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
|
||||||
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
|
setEditing(null)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors({ name: errorMessage(err) })
|
setErrors({ name: errorMessage(err) })
|
||||||
toast.error("Could not create brand", errorMessage(err))
|
toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||||
async function handleToggleActive(brand: Brand) {
|
async function handleToggleStatus(brand: Brand) {
|
||||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||||
setTogglingId(brand.brandId)
|
setDeletingId(brand.brandId)
|
||||||
try {
|
try {
|
||||||
await brandsApi.updateStatus(brand.brandId, next)
|
await brandsApi.updateStatus(brand.brandId, next)
|
||||||
|
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update brand status", errorMessage(err))
|
toast.error("Could not update brand status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingId(null)
|
setDeletingId(null)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(brand: Brand) {
|
|
||||||
setEditing(brand)
|
|
||||||
setEditName(brand.name)
|
|
||||||
setEditErrors({})
|
|
||||||
setEditOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditSubmit() {
|
|
||||||
if (!editing) return
|
|
||||||
const nextErrors = validateBrandName(editName)
|
|
||||||
setEditErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setEditSubmitting(true)
|
|
||||||
try {
|
|
||||||
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
|
||||||
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
|
||||||
const current = await brandsApi.get(editing.brandId)
|
|
||||||
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
|
|
||||||
toast.success("Brand updated", result.data.name)
|
|
||||||
setEditOpen(false)
|
|
||||||
setEditing(null)
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
setEditErrors({ name: errorMessage(err) })
|
|
||||||
toast.error("Could not update brand", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setEditSubmitting(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Input
|
<div>
|
||||||
value={name}
|
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||||
placeholder="New brand name, e.g. Bosch"
|
</div>
|
||||||
className="h-12 flex-1 text-base"
|
|
||||||
aria-label="New brand name"
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
aria-invalid={!!errors.name}
|
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
<DialogContent className="sm:max-w-sm">
|
||||||
/>
|
<DialogHeader className="items-center text-center">
|
||||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
|
||||||
<Plus className="size-5" />
|
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||||
Add Brand
|
</DialogHeader>
|
||||||
</Button>
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="brand-name">Name</FieldLabel>
|
||||||
|
<Input id="brand-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : 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={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "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 brands…"
|
||||||
|
className="h-14 w-full pl-11 text-base"
|
||||||
|
aria-label="Search brands"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
|
<SelectTrigger className="h-14! w-full sm:w-48 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>
|
</div>
|
||||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && brands === null && (
|
{!error && brands === null && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="flex flex-col gap-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -203,173 +225,162 @@ function BrandPanel() {
|
|||||||
{!error && brands !== null && brands.length === 0 && (
|
{!error && brands !== null && brands.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<Tag className="size-12 text-muted-foreground" />
|
<Tag className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">No brands yet.</p>
|
<p className="text-base text-muted-foreground">
|
||||||
|
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && brands !== null && brands.length > 0 && (
|
{!error && brands !== null && brands.length > 0 && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{brands.map((b) => (
|
<Table className="text-base">
|
||||||
<label
|
<TableHeader>
|
||||||
key={b.brandId}
|
<TableRow>
|
||||||
onDoubleClick={() => openEdit(b)}
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
title="Tick to activate/deactivate — double-click to rename"
|
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
</TableHead>
|
||||||
>
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<Checkbox
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
checked={b.status === "Active"}
|
</TableHead>
|
||||||
onCheckedChange={() => handleToggleActive(b)}
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
disabled={togglingId === b.brandId}
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
className="shrink-0"
|
</TableHead>
|
||||||
/>
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<span className="truncate font-medium">{b.name}</span>
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
</label>
|
</TableHead>
|
||||||
))}
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
)}
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedBrands!.map((b) => (
|
||||||
|
<TableRow key={b.brandId}>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${b.name}`}
|
||||||
|
onClick={() => openEditDialog(b)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
{/* Deactivate, not delete: the API has no DELETE for any master
|
||||||
<DialogContent className="sm:max-w-sm">
|
(FR-MD-08) — records referenced by transactions must survive. */}
|
||||||
<DialogHeader className="items-center text-center">
|
<AlertDialog>
|
||||||
<DialogTitle>Edit brand</DialogTitle>
|
<AlertDialogTrigger
|
||||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
render={
|
||||||
</DialogHeader>
|
<Button
|
||||||
<FieldGroup>
|
variant="ghost"
|
||||||
<Field data-invalid={!!editErrors.name}>
|
size="icon-sm"
|
||||||
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
<Input
|
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
|
||||||
id="brand-edit-name"
|
disabled={deletingId === b.brandId}
|
||||||
value={editName}
|
/>
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
}
|
||||||
aria-invalid={!!editErrors.name}
|
>
|
||||||
/>
|
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
</AlertDialogTrigger>
|
||||||
</Field>
|
<AlertDialogContent
|
||||||
</FieldGroup>
|
variant={b.status === "Active" ? "destructive" : "success"}
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
|
description={
|
||||||
Cancel
|
b.status === "Active"
|
||||||
</Button>
|
? "The brand stays on existing items but cannot be assigned to new ones."
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
|
: "The brand becomes selectable again."
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
}
|
||||||
</Button>
|
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
</div>
|
onConfirm={() => handleToggleStatus(b)}
|
||||||
</DialogContent>
|
/>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UomPanel() {
|
function SortableHeader({
|
||||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
label,
|
||||||
const [error, setError] = useState<string | null>(null)
|
active,
|
||||||
|
order,
|
||||||
const [name, setName] = useState("")
|
onClick,
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
}: {
|
||||||
const [submitting, setSubmitting] = useState(false)
|
label: string
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
active: boolean
|
||||||
|
order: SortOrder
|
||||||
function load() {
|
onClick: () => void
|
||||||
uomsApi
|
}) {
|
||||||
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
|
||||||
.then((res) => setUoms(res.items))
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [])
|
|
||||||
|
|
||||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
|
||||||
async function handleToggleActive(uom: Uom) {
|
|
||||||
const next = uom.status === "Active" ? "Inactive" : "Active"
|
|
||||||
setTogglingId(uom.uomId)
|
|
||||||
try {
|
|
||||||
await uomsApi.updateStatus(uom.uomId, next)
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not update UOM status", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setTogglingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
const nextErrors = validateUomName(name)
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const uom = await uomsApi.create({ name })
|
|
||||||
toast.success("UOM created", uom.name)
|
|
||||||
setName("")
|
|
||||||
setErrors({})
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
const fe = fieldErrors(err)
|
|
||||||
if (fe?.name) setErrors({ name: fe.name })
|
|
||||||
toast.error("Could not create UOM", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<button
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
type="button"
|
||||||
<Input
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
value={name}
|
onClick={onClick}
|
||||||
onChange={(e) => setName(e.target.value)}
|
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||||
placeholder="New UOM name, e.g. Box-12"
|
>
|
||||||
className="h-12 flex-1 text-base"
|
{label}
|
||||||
aria-label="New UOM name"
|
{active ? (
|
||||||
aria-invalid={!!errors.name}
|
order === "asc" ? (
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
<ArrowUp className="size-3.5" />
|
||||||
/>
|
) : (
|
||||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
<ArrowDown className="size-3.5" />
|
||||||
<Plus className="size-5" />
|
)
|
||||||
Add UOM
|
) : (
|
||||||
</Button>
|
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||||
</div>
|
|
||||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
{!error && uoms === null && (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && uoms !== null && uoms.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
|
||||||
<Ruler className="size-12 text-muted-foreground" />
|
|
||||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && uoms !== null && uoms.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{uoms.map((u) => (
|
|
||||||
<label
|
|
||||||
key={u.uomId}
|
|
||||||
title="Tick to activate/deactivate"
|
|
||||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={u.status === "Active"}
|
|
||||||
onCheckedChange={() => handleToggleActive(u)}
|
|
||||||
disabled={togglingId === u.uomId}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="truncate font-medium">{u.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,139 +166,71 @@ export default function CategorySubCategoriesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && subCategories !== null && subCategories.length > 0 && (
|
{!error && subCategories !== null && subCategories.length > 0 && (
|
||||||
<>
|
<Table className="text-base">
|
||||||
{/* Mobile: stacked cards. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{subCategories.map((s) => (
|
{subCategories.map((s) => (
|
||||||
<div key={s.subCategoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow key={s.subCategoryId}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||||
<div className="min-w-0">
|
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||||
<p className="truncate font-medium text-foreground">{s.name}</p>
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="text-sm text-muted-foreground">#{s.subCategoryId}</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
"border-transparent",
|
||||||
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.status}
|
{s.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="border-t pt-3 text-sm">
|
<AlertDialog>
|
||||||
<p className="text-muted-foreground">Created</p>
|
<AlertDialogTrigger
|
||||||
<p className="font-medium text-foreground">{new Date(s.createdAt).toLocaleDateString()}</p>
|
render={
|
||||||
</div>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
size="icon-sm"
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
<Pencil className="size-4" />
|
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
|
||||||
</Button>
|
disabled={togglingId === s.subCategoryId}
|
||||||
|
/>
|
||||||
<AlertDialog>
|
}
|
||||||
<AlertDialogTrigger
|
>
|
||||||
render={
|
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
<Button
|
</AlertDialogTrigger>
|
||||||
variant="ghost"
|
<AlertDialogContent
|
||||||
size="icon-sm"
|
variant={s.status === "Active" ? "destructive" : "success"}
|
||||||
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
|
||||||
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
|
description={
|
||||||
disabled={togglingId === s.subCategoryId}
|
s.status === "Active"
|
||||||
/>
|
? "It stays on existing items but cannot be assigned to new ones."
|
||||||
}
|
: "It becomes selectable again."
|
||||||
>
|
}
|
||||||
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
</AlertDialogTrigger>
|
onConfirm={() => handleToggleStatus(s)}
|
||||||
<AlertDialogContent
|
/>
|
||||||
variant={s.status === "Active" ? "destructive" : "success"}
|
</AlertDialog>
|
||||||
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
|
</div>
|
||||||
description={
|
</TableCell>
|
||||||
s.status === "Active"
|
</TableRow>
|
||||||
? "It stays on existing items but cannot be assigned to new ones."
|
|
||||||
: "It becomes selectable again."
|
|
||||||
}
|
|
||||||
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => handleToggleStatus(s)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{subCategories.map((s) => (
|
|
||||||
<TableRow key={s.subCategoryId}>
|
|
||||||
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"border-transparent",
|
|
||||||
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
|
||||||
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
|
|
||||||
disabled={togglingId === s.subCategoryId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant={s.status === "Active" ? "destructive" : "success"}
|
|
||||||
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
|
|
||||||
description={
|
|
||||||
s.status === "Active"
|
|
||||||
? "It stays on existing items but cannot be assigned to new ones."
|
|
||||||
: "It becomes selectable again."
|
|
||||||
}
|
|
||||||
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => handleToggleStatus(s)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { productConfig } from "@/lib/api/product-config"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Category, ProductConfig } from "@/types/master-data"
|
import { Category } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -18,7 +17,6 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -28,7 +26,7 @@ type SortOrder = "asc" | "desc"
|
|||||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 5
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||||
@@ -50,36 +48,6 @@ export default function CategoriesPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
|
||||||
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
productConfig().then(setConfig).catch(() => setConfig(null))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
|
||||||
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
|
||||||
// is needed) once the feature is on and the page's categories are known.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
|
||||||
setSubCategoryCounts({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
|
||||||
Promise.all(
|
|
||||||
categories.map((c) =>
|
|
||||||
categoriesApi
|
|
||||||
.listSubCategories(c.categoryId, { pageSize: 1 })
|
|
||||||
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
|
|
||||||
.catch(() => [c.categoryId, 0] as const)
|
|
||||||
)
|
|
||||||
).then((entries) => {
|
|
||||||
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [config?.subcategoriesEnabled, categories])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||||
@@ -264,187 +232,128 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
{!error && categories !== null && categories.length > 0 && (
|
{!error && categories !== null && categories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: stacked cards. */}
|
<Table className="text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
{sortedCategories!.map((c) => (
|
<TableRow>
|
||||||
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||||
<div className="min-w-0">
|
</TableHead>
|
||||||
<p className="truncate font-medium text-foreground">{c.name}</p>
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
</div>
|
</TableHead>
|
||||||
<Badge
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
variant="outline"
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
className={cn(
|
</TableHead>
|
||||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
)}
|
</TableHead>
|
||||||
>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
{c.status}
|
</TableRow>
|
||||||
</Badge>
|
</TableHeader>
|
||||||
</div>
|
<TableBody>
|
||||||
|
{sortedCategories!.map((c) => (
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableRow key={c.categoryId}>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||||
<p className="text-muted-foreground">Created</p>
|
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||||
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
|
<TableCell className="px-3 py-3.5">
|
||||||
</div>
|
<Badge
|
||||||
{config?.subcategoriesEnabled && (
|
variant="outline"
|
||||||
<div>
|
className={cn(
|
||||||
<p className="text-muted-foreground">Subcategories</p>
|
"border-transparent",
|
||||||
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
|
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
||||||
{config?.subcategoriesEnabled && (
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Manage subcategories of ${c.name}`}
|
|
||||||
>
|
|
||||||
<Network className="size-4" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
|
||||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
|
||||||
disabled={deletingId === c.categoryId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant={c.status === "Active" ? "destructive" : "success"}
|
|
||||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
|
||||||
description={
|
|
||||||
c.status === "Active"
|
|
||||||
? "The category stays on existing items but cannot take new subcategories or items."
|
|
||||||
: "The category becomes selectable again."
|
|
||||||
}
|
|
||||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => handleToggleStatus(c)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
|
||||||
</TableHead>
|
|
||||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{sortedCategories!.map((c) => (
|
|
||||||
<TableRow key={c.categoryId}>
|
|
||||||
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"border-transparent",
|
|
||||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
|
||||||
{config?.subcategoriesEnabled && (
|
|
||||||
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
|
||||||
)}
|
)}
|
||||||
<TableCell className="px-3 py-3.5">
|
>
|
||||||
<div className="flex items-center gap-1">
|
{c.status}
|
||||||
{/* Subcategories are their own resource now, not a nested tree.
|
</Badge>
|
||||||
Hidden while the feature is switched off in Product Configuration. */}
|
</TableCell>
|
||||||
{config?.subcategoriesEnabled && (
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<Link
|
<TableCell className="px-3 py-3.5">
|
||||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
<div className="flex items-center gap-1">
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
{/* Subcategories are their own resource now, not a nested tree. */}
|
||||||
aria-label={`Manage subcategories of ${c.name}`}
|
<Link
|
||||||
>
|
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||||
<Network className="size-4" />
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
</Link>
|
aria-label={`Manage subcategories of ${c.name}`}
|
||||||
)}
|
>
|
||||||
|
<Network className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label={`Edit ${c.name}`}
|
aria-label={`Edit ${c.name}`}
|
||||||
onClick={() => openEditDialog(c)}
|
onClick={() => openEditDialog(c)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||||
disabled={deletingId === c.categoryId}
|
disabled={deletingId === c.categoryId}
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant={c.status === "Active" ? "destructive" : "success"}
|
|
||||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
|
||||||
description={
|
|
||||||
c.status === "Active"
|
|
||||||
? "The category stays on existing items but cannot take new subcategories or items."
|
|
||||||
: "The category becomes selectable again."
|
|
||||||
}
|
|
||||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => handleToggleStatus(c)}
|
|
||||||
/>
|
/>
|
||||||
</AlertDialog>
|
}
|
||||||
</div>
|
>
|
||||||
</TableCell>
|
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</TableRow>
|
</AlertDialogTrigger>
|
||||||
))}
|
<AlertDialogContent
|
||||||
</TableBody>
|
variant={c.status === "Active" ? "destructive" : "success"}
|
||||||
</Table>
|
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||||
</div>
|
description={
|
||||||
</div>
|
c.status === "Active"
|
||||||
|
? "The category stays on existing items but cannot take new subcategories or items."
|
||||||
|
: "The category becomes selectable again."
|
||||||
|
}
|
||||||
|
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => handleToggleStatus(c)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function ItemTypesPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{itemTypes.map((t) => (
|
{itemTypes.map((t) => (
|
||||||
<TableRow key={t.itemTypeId}>
|
<TableRow key={t.itemTypeId}>
|
||||||
<TableCell className="px-3 py-3.5">{t.itemTypeId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
|
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
|
||||||
@@ -215,7 +215,7 @@ export default function ItemTypesPage() {
|
|||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
|
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -721,122 +720,136 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
{config?.sellingPricesEnabled && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
{config?.sellingPricesEnabled && (
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div>
|
||||||
<div>
|
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
|
||||||
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<RadioGroup
|
<div className="inline-flex w-fit rounded-lg border p-1">
|
||||||
value={priceMode}
|
<button
|
||||||
onValueChange={(v) => setPriceMode(v as typeof priceMode)}
|
type="button"
|
||||||
className="flex flex-col gap-3"
|
onClick={() => setPriceMode("stock")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||||
|
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<label className="flex items-center gap-2.5">
|
Use stock value
|
||||||
<RadioGroupItem value="stock" />
|
</button>
|
||||||
<span className="text-base font-medium">Use stock value</span>
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<label className="flex items-center gap-2.5">
|
onClick={() => setPriceMode("fixed")}
|
||||||
<RadioGroupItem value="fixed" />
|
className={cn(
|
||||||
<span className="text-base font-medium">Fixed price</span>
|
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||||
</label>
|
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||||
</RadioGroup>
|
)}
|
||||||
|
>
|
||||||
|
Fixed price
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex max-w-xl flex-wrap items-end gap-3">
|
||||||
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
||||||
<Label className="text-base">Bulk default flat price</Label>
|
<Label className="text-base">Bulk default flat price</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={defaultFlatPrice}
|
value={defaultFlatPrice}
|
||||||
onChange={(e) => setDefaultFlatPrice(e.target.value)}
|
onChange={(e) => setDefaultFlatPrice(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const raw = defaultFlatPrice.trim()
|
const raw = defaultFlatPrice.trim()
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
setPricesByKey((prev) => {
|
setPricesByKey((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
if (next[variant.key] == null || next[variant.key].trim() === "") {
|
if (next[variant.key] == null || next[variant.key].trim() === "") {
|
||||||
next[variant.key] = raw
|
next[variant.key] = raw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Fill blank flat prices
|
Fill blank flat prices
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This copies the same value into blank flat-price cells only.
|
This copies the same value into blank flat-price cells only.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{config?.warrantyPeriodsEnabled && (
|
{config?.warrantyPeriodsEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Mark whether every generated variant is sold under warranty.
|
Mark whether every generated variant is sold under warranty.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RadioGroup
|
<div className="inline-flex w-fit rounded-lg border p-1">
|
||||||
value={warranty}
|
<button
|
||||||
onValueChange={(v) => {
|
type="button"
|
||||||
setWarranty(v as typeof warranty)
|
onClick={() => {
|
||||||
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
|
setWarranty("NonWarranty")
|
||||||
}}
|
setWarrantyPeriodMonths(null)
|
||||||
className="flex flex-col gap-3"
|
}}
|
||||||
>
|
className={cn(
|
||||||
<label className="flex items-center gap-2.5">
|
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||||
<RadioGroupItem value="NonWarranty" />
|
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||||
<span className="text-base font-medium">Non-Warranty</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2.5">
|
|
||||||
<RadioGroupItem value="Warranty" />
|
|
||||||
<span className="text-base font-medium">Warranty</span>
|
|
||||||
</label>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
{warranty === "Warranty" && (
|
|
||||||
<div className="mt-2 flex flex-col gap-1.5">
|
|
||||||
<Label>Warranty period +</Label>
|
|
||||||
<Select<WarrantyPeriodMonths>
|
|
||||||
value={warrantyPeriodMonths}
|
|
||||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
|
||||||
<SelectValue placeholder="Select period" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
|
||||||
<SelectItem key={months} value={months} className="text-base">
|
|
||||||
{months} months
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
Non-Warranty
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWarranty("Warranty")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||||
|
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Warranty
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warranty === "Warranty" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Warranty period</Label>
|
||||||
|
<Select<WarrantyPeriodMonths>
|
||||||
|
value={warrantyPeriodMonths}
|
||||||
|
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||||
|
<SelectValue placeholder="Select period" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||||
|
<SelectItem key={months} value={months} className="text-base">
|
||||||
|
{months} months
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -875,7 +888,7 @@ export default function NewItemPage() {
|
|||||||
key={t.itemTypeId}
|
key={t.itemTypeId}
|
||||||
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
|
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||||
blocked ? "opacity-50" : "hover:bg-muted/50"
|
blocked ? "opacity-50" : "hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1034,7 +1047,7 @@ export default function NewItemPage() {
|
|||||||
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||||
{/* Read-only: content is derived, so an editable cell here could only
|
{/* Read-only: content is derived, so an editable cell here could only
|
||||||
disagree with the value that produced it. */}
|
disagree with the value that produced it. */}
|
||||||
<TableCell className="px-3 py-2.5">
|
<TableCell className="px-3 py-2.5 text-muted-foreground">
|
||||||
{contentLabelFor(variant)}
|
{contentLabelFor(variant)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
|
|||||||
@@ -1,80 +1,34 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
||||||
AlertTriangle,
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowUpDown,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
ListTree,
|
|
||||||
Package,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Ruler,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { productConfig } from "@/lib/api/product-config"
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { downloadCsv } from "@/lib/csv-export"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
|
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { ColumnsMenu } from "@/components/ui/columns-menu"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
|
type TrackingFilter = TrackingMode | "All"
|
||||||
type SortOrder = "asc" | "desc"
|
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
const COLUMN_OPTIONS = [
|
|
||||||
{ key: "categoryBrand", label: "Category / Brand" },
|
|
||||||
{ key: "stockValue", label: "Stock Value" },
|
|
||||||
{ key: "lastPurchase", label: "Last Purchase" },
|
|
||||||
{ key: "sales30d", label: "Sales (30d)" },
|
|
||||||
{ key: "status", label: "Status" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
|
||||||
return `LKR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
|
|
||||||
{label}
|
|
||||||
{active ? (
|
|
||||||
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ItemsPage() {
|
export default function ItemsPage() {
|
||||||
const router = useRouter()
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const [items, setItems] = useState<ItemOverview[] | null>(null)
|
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||||
const [brands, setBrands] = useState<Brand[]>([])
|
const [brands, setBrands] = useState<Brand[]>([])
|
||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
@@ -84,158 +38,74 @@ export default function ItemsPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
||||||
|
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("sku")
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
|
useEffect(() => setPage(1), [query, status, categoryId, trackingMode])
|
||||||
// pages) — restarts at page 1. Reset during render rather than in an effect.
|
|
||||||
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
|
|
||||||
const [prevResetKey, setPrevResetKey] = useState(resetKey)
|
|
||||||
if (prevResetKey !== resetKey) {
|
|
||||||
setPrevResetKey(resetKey)
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
function load() {
|
||||||
let cancelled = false
|
setError(null)
|
||||||
// Mobile "Show more" appends the next server page to what's already listed.
|
|
||||||
const append = isMobile && page > 1
|
|
||||||
itemsApi
|
itemsApi
|
||||||
.overview({
|
.list({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize: PAGE_SIZE,
|
||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
categoryId: categoryId === "All" ? undefined : categoryId,
|
categoryId: categoryId === "All" ? undefined : categoryId,
|
||||||
|
trackingMode: trackingMode === "All" ? undefined : trackingMode,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return
|
setItems(res.items)
|
||||||
setError(null)
|
|
||||||
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
|
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
if (!append) setSelected(new Set())
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => setError(errorMessage(err)))
|
||||||
if (!cancelled) setError(errorMessage(err))
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoadingMore(false)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [page, pageSize, query, status, categoryId, isMobile])
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
setLoadingMore(true)
|
|
||||||
setPage((p) => p + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(load, [page, query, status, categoryId, trackingMode])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||||
productConfig().then(setConfig).catch(() => {})
|
productConfig().then(setConfig).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// No "list all subcategories" endpoint exists — they're scoped per category — so once
|
||||||
|
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
|
||||||
|
useEffect(() => {
|
||||||
|
if (categories.length === 0) return
|
||||||
|
Promise.all(
|
||||||
|
categories.map((c) =>
|
||||||
|
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
|
||||||
|
)
|
||||||
|
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
function categoryName(id: number) {
|
function categoryName(id: number) {
|
||||||
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function brandName(id: number | null) {
|
function subCategoryName(id: number) {
|
||||||
if (id === null) return "—"
|
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandName(id: number) {
|
||||||
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
|
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
|
||||||
|
|
||||||
const showCol = (key: string) => visibleColumns[key] ?? true
|
|
||||||
function toggleColumn(key: string, next: boolean) {
|
|
||||||
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
|
||||||
if (key === sortKey) {
|
|
||||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
|
||||||
} else {
|
|
||||||
setSortKey(key)
|
|
||||||
setSortOrder("asc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
if (!items) return null
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
let cmp: number
|
|
||||||
switch (sortKey) {
|
|
||||||
case "stockValue":
|
|
||||||
cmp = a.stockValue - b.stockValue
|
|
||||||
break
|
|
||||||
case "salesQtyLast30Days":
|
|
||||||
cmp = a.salesQtyLast30Days - b.salesQtyLast30Days
|
|
||||||
break
|
|
||||||
case "lastPurchaseDate":
|
|
||||||
cmp = (a.lastPurchaseDate ? new Date(a.lastPurchaseDate).getTime() : 0) -
|
|
||||||
(b.lastPurchaseDate ? new Date(b.lastPurchaseDate).getTime() : 0)
|
|
||||||
break
|
|
||||||
case "name":
|
|
||||||
cmp = a.name.localeCompare(b.name)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
cmp = a.sku.localeCompare(b.sku)
|
|
||||||
}
|
|
||||||
return sortOrder === "asc" ? cmp : -cmp
|
|
||||||
})
|
|
||||||
}, [items, sortKey, sortOrder])
|
|
||||||
|
|
||||||
const allSelected = !!sortedItems && sortedItems.length > 0 && selected.size === sortedItems.length
|
|
||||||
function toggleAll(checked: boolean) {
|
|
||||||
setSelected(checked && sortedItems ? new Set(sortedItems.map((i) => i.itemId)) : new Set())
|
|
||||||
}
|
|
||||||
function toggleRow(itemId: number, checked: boolean) {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (checked) next.add(itemId)
|
|
||||||
else next.delete(itemId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExport() {
|
|
||||||
if (!sortedItems) return
|
|
||||||
const rows = selected.size > 0 ? sortedItems.filter((i) => selected.has(i.itemId)) : sortedItems
|
|
||||||
downloadCsv(`items-${new Date().toISOString().slice(0, 10)}`, rows, [
|
|
||||||
{ header: "SKU", value: (i) => i.sku },
|
|
||||||
{ header: "Name", value: (i) => i.name },
|
|
||||||
{ header: "Category", value: (i) => categoryName(i.categoryId) },
|
|
||||||
{ header: "Brand", value: (i) => brandName(i.brandId) },
|
|
||||||
{ header: "Status", value: (i) => i.status },
|
|
||||||
{ header: "Stock Qty", value: (i) => i.stockQty },
|
|
||||||
{ header: "Stock Value (LKR)", value: (i) => i.stockValue.toFixed(2) },
|
|
||||||
{ header: "Low Stock", value: (i) => (i.isLowStock ? "Yes" : "No") },
|
|
||||||
{ header: "Last Purchase Date", value: (i) => i.lastPurchaseDate ?? "" },
|
|
||||||
{ header: "Last Supplier", value: (i) => i.lastSupplierName ?? "" },
|
|
||||||
{ header: "Sales Qty (30d)", value: (i) => i.salesQtyLast30Days },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||||
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
|
<p className="text-base text-muted-foreground">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
<Ruler className="size-5" />
|
<Ruler className="size-5" />
|
||||||
UOMs
|
UOMs
|
||||||
@@ -279,8 +149,19 @@ export default function ItemsPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select<TrackingFilter> value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}>
|
||||||
|
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||||
|
<SelectValue placeholder="All tracking modes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All" className="text-base">All tracking modes</SelectItem>
|
||||||
|
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||||
|
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
||||||
|
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -289,18 +170,13 @@ export default function ItemsPage() {
|
|||||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
|
|
||||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
|
||||||
<Download className="size-4" />
|
|
||||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && sortedItems === null && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
@@ -308,7 +184,7 @@ export default function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && sortedItems !== null && sortedItems.length === 0 && (
|
{!error && items !== null && items.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<Package className="size-12 text-muted-foreground" />
|
<Package className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
|
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
|
||||||
@@ -321,239 +197,92 @@ export default function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && sortedItems !== null && sortedItems.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<Table className="text-base">
|
||||||
{sortedItems.map((item) => (
|
<TableHeader>
|
||||||
<div
|
<TableRow>
|
||||||
key={item.itemId}
|
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
|
||||||
role="link"
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
tabIndex={0}
|
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
||||||
onClick={(e) => {
|
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
|
||||||
router.push(`/dashboard/products/${item.itemId}`)
|
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||||
}}
|
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
||||||
onKeyDown={(e) => {
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
if (e.key === "Enter") router.push(`/dashboard/products/${item.itemId}`)
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
}}
|
</TableRow>
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
</TableHeader>
|
||||||
>
|
<TableBody>
|
||||||
<div className="flex items-start justify-between gap-2">
|
{items.map((item) => (
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<TableRow key={item.itemId}>
|
||||||
<Checkbox
|
<TableCell className="px-3 py-3.5 font-medium">
|
||||||
checked={selected.has(item.itemId)}
|
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
|
||||||
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
|
{item.sku}
|
||||||
aria-label={`Select ${item.sku}`}
|
</Link>
|
||||||
data-no-row-nav
|
</TableCell>
|
||||||
className="mt-0.5 shrink-0"
|
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||||
/>
|
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
{config?.subcategoriesEnabled && (
|
||||||
<Package className="size-4 text-primary" />
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
</div>
|
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
||||||
<div className="min-w-0">
|
</TableCell>
|
||||||
<p className="truncate font-medium text-foreground">{item.name}</p>
|
)}
|
||||||
<p className="text-sm text-muted-foreground">{item.sku}</p>
|
{config?.brandsEnabled && (
|
||||||
</div>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
</div>
|
{item.brandId !== null ? brandName(item.brandId) : "—"}
|
||||||
{showCol("status") && (
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.status}
|
{item.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Link
|
||||||
{showCol("categoryBrand") && (
|
href={`/dashboard/products/${item.itemId}`}
|
||||||
<p className="text-sm text-muted-foreground">
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
{categoryName(item.categoryId)}
|
aria-label={`Edit ${item.sku}`}
|
||||||
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
|
>
|
||||||
</p>
|
<Pencil className="size-4" />
|
||||||
)}
|
</Link>
|
||||||
|
</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
{showCol("stockValue") && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Stock Value</p>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
|
|
||||||
{item.isLowStock && (
|
|
||||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
|
||||||
<AlertTriangle className="size-3" />
|
|
||||||
Low
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showCol("sales30d") && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Sales (30d)</p>
|
|
||||||
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showCol("lastPurchase") && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Last Purchase</p>
|
|
||||||
{item.lastPurchaseDate ? (
|
|
||||||
<>
|
|
||||||
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-foreground">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/products/${item.itemId}`}
|
|
||||||
data-no-row-nav
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
|
|
||||||
aria-label={`Edit ${item.sku}`}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && pagination.page < pagination.totalPages && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={showMore} disabled={loadingMore}>
|
|
||||||
{loadingMore ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 w-10 px-3">
|
|
||||||
<Checkbox checked={allSelected} onCheckedChange={(c) => toggleAll(c === true)} aria-label="Select all" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
|
|
||||||
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
|
|
||||||
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
|
|
||||||
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
|
|
||||||
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
|
|
||||||
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{sortedItems.map((item) => (
|
</Table>
|
||||||
<TableRow
|
|
||||||
key={item.itemId}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/products/${item.itemId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(item.itemId)}
|
|
||||||
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
|
|
||||||
aria-label={`Select ${item.sku}`}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Package className="size-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
{item.sku}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
|
||||||
{showCol("categoryBrand") && (
|
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
|
||||||
<p className="text-foreground">{categoryName(item.categoryId)}</p>
|
|
||||||
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{showCol("stockValue") && (
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{formatMoney(item.stockValue)}</span>
|
|
||||||
{item.isLowStock && (
|
|
||||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
|
||||||
<AlertTriangle className="size-3" />
|
|
||||||
Low
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{showCol("lastPurchase") && (
|
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
|
||||||
{item.lastPurchaseDate ? (
|
|
||||||
<>
|
|
||||||
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
|
||||||
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
|
|
||||||
{showCol("status") && (
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/products/${item.itemId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View ${item.sku}`}
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/products/${item.itemId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Edit ${item.sku}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center justify-between">
|
||||||
<PaginationFooter
|
<p className="text-sm text-muted-foreground">
|
||||||
pagination={pagination}
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
onPageChange={setPage}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
</p>
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function ProductSettingsPage() {
|
|||||||
{!error && !config && <Skeleton className="h-80 w-full" />}
|
{!error && !config && <Skeleton className="h-80 w-full" />}
|
||||||
|
|
||||||
{!error && config && (
|
{!error && config && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
<div className="flex flex-col gap-4 rounded-xl border p-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="size-5 text-primary" />
|
<Package className="size-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
|
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, Pencil, Percent, Plus, Search } from "lucide-react"
|
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -17,7 +17,6 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -346,7 +345,39 @@ export default function TaxRatesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,129 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { Plus, Ruler } from "lucide-react"
|
||||||
|
|
||||||
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
// to keep old links/bookmarks working.
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
export default function UomsRedirectPage() {
|
import { validateUomName } from "@/lib/validations/master-data"
|
||||||
const router = useRouter()
|
import { Uom } from "@/types/master-data"
|
||||||
|
|
||||||
useEffect(() => {
|
import { Button } from "@/components/ui/button"
|
||||||
router.replace("/dashboard/products/brands?tab=uom")
|
import { Input } from "@/components/ui/input"
|
||||||
}, [router])
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
return null
|
export default function UomsPage() {
|
||||||
|
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors = validateUomName(name)
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const uom = await uomsApi.create({ name })
|
||||||
|
toast.success("UOM created", uom.name)
|
||||||
|
setOpen(false)
|
||||||
|
setName("")
|
||||||
|
setErrors({})
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.name) setErrors({ name: fe.name })
|
||||||
|
toast.error("Could not create UOM", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New UOM</Button>} />
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>New UOM</DialogTitle>
|
||||||
|
<DialogDescription>e.g. EA, KG, Box-12.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="uom-name">Name</FieldLabel>
|
||||||
|
<Input id="uom-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : 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>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && uoms === null && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && uoms !== null && uoms.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<Ruler className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && uoms !== null && uoms.length > 0 && (
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<TableRow key={u.uomId}>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{u.name}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
|
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
@@ -154,11 +154,11 @@ export default function GrnDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex items-center gap-2">
|
||||||
{grn.status !== "Draft" && (
|
{grn.status !== "Draft" && (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/ledgers/general-ledger"
|
href="/dashboard/ledgers/general-ledger"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||||
title="View this GRN's postings in the General Ledger report"
|
title="View this GRN's postings in the General Ledger report"
|
||||||
>
|
>
|
||||||
<BookOpen className="size-5" />
|
<BookOpen className="size-5" />
|
||||||
@@ -166,7 +166,7 @@ export default function GrnDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{grn.status === "Draft" && (
|
{grn.status === "Draft" && (
|
||||||
<Button size="lg" className="w-full sm:w-auto" onClick={handleConfirm} disabled={confirming}>
|
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||||
<PackageCheck className="size-5" />
|
<PackageCheck className="size-5" />
|
||||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,110 +193,7 @@ export default function GrnDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{grn.lines.map((line) => {
|
|
||||||
const item = itemFor(line.itemId)
|
|
||||||
return (
|
|
||||||
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
|
|
||||||
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
|
|
||||||
</div>
|
|
||||||
{line.warrantyNumbers.length > 0 && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<HoldStatusBadge status={line.holdStatus} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">UOM / Bin</p>
|
|
||||||
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)} · {binFor(line.binId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Qty</p>
|
|
||||||
<p className="font-medium text-foreground">{line.qty}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Unit cost</p>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{line.unitCost.toFixed(2)}
|
|
||||||
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
|
|
||||||
<span className="block text-xs text-warning">
|
|
||||||
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Disc % / Net cost</p>
|
|
||||||
<p className="font-medium text-foreground">{line.discountPct.toFixed(2)}% / {line.netUnitCost.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Received value</p>
|
|
||||||
<p className="font-medium text-foreground">{line.receivedValue.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">VAT</p>
|
|
||||||
<p className="font-medium text-foreground">{line.vatAmount.toFixed(2)} ({line.vatPct.toFixed(2)}%)</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Line total</p>
|
|
||||||
<p className="font-semibold text-foreground">{line.lineTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{grn.status === "Confirmed" && (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
{line.holdStatus === "OnHold" ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
disabled={releasingLineId === line.grnLineId}
|
|
||||||
onClick={() => handleRelease(line.grnLineId, "Release")}
|
|
||||||
>
|
|
||||||
<ShieldAlert className="size-4" />
|
|
||||||
Release
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={releasingLineId === line.grnLineId}
|
|
||||||
onClick={() => handleRelease(line.grnLineId, "Reject")}
|
|
||||||
>
|
|
||||||
<XCircle className="size-4" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : line.holdStatus === "Rejected" ? (
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
|
||||||
>
|
|
||||||
<PackageX className="size-4" />
|
|
||||||
Create Return
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">No actions</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -442,9 +339,9 @@ export default function GrnDetailPage() {
|
|||||||
<span className="text-sm text-muted-foreground">This GRN hasn't been added to a purchase invoice yet.</span>
|
<span className="text-sm text-muted-foreground">This GRN hasn't been added to a purchase invoice yet.</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
|
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
|
||||||
className={cn(buttonVariants({ size: "sm" }), "rounded-full")}
|
className={cn(buttonVariants({ size: "sm" }))}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<FileText className="size-4" />
|
||||||
Create Purchase Invoice
|
Create Purchase Invoice
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -834,190 +834,7 @@ export default function NewGrnPage() {
|
|||||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||||
|
|
||||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||||
<>
|
<div className="overflow-x-auto">
|
||||||
{/* Mobile: stacked editable cards. */}
|
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{lines.map((line) => {
|
|
||||||
const item = itemFor(line.itemId)
|
|
||||||
const errors = lineErrors[line.key] ?? {}
|
|
||||||
const c = computeLine(line)
|
|
||||||
return (
|
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-sm">Item</Label>
|
|
||||||
{line.poLineId ? (
|
|
||||||
<div className="flex h-11 items-center gap-2 text-sm">
|
|
||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
|
||||||
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{item?.warranty === "Warranty" && (
|
|
||||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
|
||||||
)}
|
|
||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">UOM</Label>
|
|
||||||
<div className="flex h-11 items-center text-sm text-muted-foreground">
|
|
||||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Bin</Label>
|
|
||||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-sm">
|
|
||||||
<SelectValue placeholder="None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{bins.map((b) => (
|
|
||||||
<SelectItem key={b.binId} value={b.binId} className="text-sm">
|
|
||||||
{b.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item?.trackingMode === "Batch" && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Batch no</Label>
|
|
||||||
<Input
|
|
||||||
value={line.batchNo}
|
|
||||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
|
||||||
placeholder="Batch no"
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Qty</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.qty}
|
|
||||||
aria-invalid={!!errors.qty}
|
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Unit cost</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.unitCost}
|
|
||||||
aria-invalid={!!errors.unitCost}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
|
||||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
|
||||||
<p className="mt-1 text-xs text-warning">PO price {line.poUnitPrice.toFixed(2)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Disc %</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.discountPct}
|
|
||||||
aria-invalid={!!errors.discountPct}
|
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
|
||||||
disabled={headerDiscountPct.trim() !== ""}
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">VAT %</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.vatPct}
|
|
||||||
aria-invalid={!!errors.vatPct}
|
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
|
||||||
disabled={headerVatPct.trim() !== ""}
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Hold status</Label>
|
|
||||||
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Available" className="text-sm">Available</SelectItem>
|
|
||||||
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item?.warranty === "Warranty" && (
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
|
|
||||||
<ShieldCheck className="size-4" />
|
|
||||||
Add warranty numbers
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-3">
|
|
||||||
<span className="text-sm text-muted-foreground">Line total</span>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="font-semibold tabular-nums">{c.lineTotal.toFixed(2)}</span>
|
|
||||||
<p className="text-xs text-muted-foreground">net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-sm">
|
<Table className="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -1226,8 +1043,7 @@ export default function NewGrnPage() {
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!poLoading && lineTab === "warranty" && (
|
{!poLoading && lineTab === "warranty" && (
|
||||||
@@ -1307,7 +1123,7 @@ export default function NewGrnPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{Array.from({ length: units }, (_, i) => (
|
{Array.from({ length: units }, (_, i) => (
|
||||||
<TableRow key={`${line.key}-${i}`}>
|
<TableRow key={`${line.key}-${i}`}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm">
|
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
|
||||||
{i + 1} of {units}
|
{i + 1} of {units}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
@@ -1363,11 +1179,11 @@ export default function NewGrnPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{lineTab === "lines" && (
|
{lineTab === "lines" && (
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||||
{submitting ? "Creating…" : "Create GRN"}
|
{submitting ? "Creating…" : "Create GRN"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,15 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||||
import { Vendor, Warehouse } from "@/types/master-data"
|
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
@@ -18,7 +14,6 @@ import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
@@ -34,19 +29,13 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = GrnStatus | "All"
|
type StatusFilter = GrnStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE = 5
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function GrnListPage() {
|
export default function GrnListPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
const stats = useDashboardStats()
|
const stats = useDashboardStats()
|
||||||
|
|
||||||
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -54,22 +43,6 @@ export default function GrnListPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
|
||||||
warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function vendorName(id: number) {
|
|
||||||
const vendor = vendors.find((v) => v.vendorId === id)
|
|
||||||
return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}`
|
|
||||||
}
|
|
||||||
function warehouseName(id: number) {
|
|
||||||
const warehouse = warehouses.find((w) => w.warehouseId === id)
|
|
||||||
return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Debounce the search box so typing doesn't refetch on every keystroke.
|
// Debounce the search box so typing doesn't refetch on every keystroke.
|
||||||
@@ -88,7 +61,7 @@ export default function GrnListPage() {
|
|||||||
grnsApi
|
grnsApi
|
||||||
.list({
|
.list({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize: PAGE_SIZE,
|
||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
@@ -98,15 +71,9 @@ export default function GrnListPage() {
|
|||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowMore() {
|
useEffect(load, [page, query, status, activeBranchId, branchingEnabled])
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
|
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
@@ -119,7 +86,7 @@ export default function GrnListPage() {
|
|||||||
Receive goods against a purchase order, or record a direct receipt.
|
Receive goods against a purchase order, or record a direct receipt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New GRN
|
New GRN
|
||||||
</Link>
|
</Link>
|
||||||
@@ -172,21 +139,9 @@ export default function GrnListPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && grns === null && (
|
{!error && grns === null && (
|
||||||
<div className="overflow-hidden rounded-xl border">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="ml-4 h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,165 +175,101 @@ export default function GrnListPage() {
|
|||||||
|
|
||||||
{!error && grns !== null && grns.length > 0 && (
|
{!error && grns !== null && grns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: stacked cards. */}
|
<Table className="text-base">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableHeader>
|
||||||
{grns.map((grn) => (
|
<TableRow>
|
||||||
<div
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
key={grn.grnId}
|
<TableHead className="h-12 px-3 text-sm">PO</TableHead>
|
||||||
role="link"
|
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||||
tabIndex={0}
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
onClick={(e) => {
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
}}
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
onKeyDown={(e) => {
|
</TableRow>
|
||||||
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
</TableHeader>
|
||||||
}}
|
<TableBody>
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
{grns.map((grn) => {
|
||||||
>
|
return (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableRow key={grn.grnId}>
|
||||||
<div className="min-w-0">
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="truncate font-semibold text-foreground">{grn.docNo}</p>
|
<Link
|
||||||
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
|
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||||
</div>
|
className="font-medium text-foreground hover:underline"
|
||||||
<GrnStatusBadge status={grn.status} />
|
>
|
||||||
</div>
|
{grn.docNo}
|
||||||
|
</Link>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell>
|
||||||
<p className="text-muted-foreground">PO</p>
|
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell>
|
||||||
<p className="font-medium text-foreground" data-no-row-nav>
|
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell>
|
||||||
{grn.poId ? (
|
<TableCell className="px-3 py-3.5">
|
||||||
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
|
<GrnStatusBadge status={grn.status} />
|
||||||
PO {String(grn.poId).padStart(2, "0")}
|
</TableCell>
|
||||||
</Link>
|
<TableCell className="px-3 py-3.5">
|
||||||
) : (
|
|
||||||
"Direct"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Warehouse</p>
|
|
||||||
<p className="font-medium text-foreground">{warehouseName(grn.warehouseId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Invoice</p>
|
|
||||||
<p className="font-medium text-foreground" data-no-row-nav>
|
|
||||||
{grn.status !== "Confirmed" ? (
|
{grn.status !== "Confirmed" ? (
|
||||||
"—"
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
) : grn.purchaseInvoiceId ? (
|
) : grn.purchaseInvoiceId ? (
|
||||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
|
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline">
|
||||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"Not invoiced"
|
<span className="text-sm text-muted-foreground">Not invoiced</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||||
<div>
|
<TableCell className="px-3 py-3.5">
|
||||||
<p className="text-muted-foreground">Created</p>
|
<div className="flex items-center gap-1">
|
||||||
<p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
|
<Link
|
||||||
</div>
|
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||||
</div>
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
</div>
|
aria-label={`View ${grn.docNo}`}
|
||||||
))}
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
{pagination && grns.length < pagination.totalItems && (
|
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
a GRN. A receipt is corrected with a reversing document, never
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
edited or erased (FR-X-05). */}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-40">Doc No</TableHead>
|
|
||||||
<TableHead>PO</TableHead>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>Warehouse</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Invoice</TableHead>
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
<TableHead className="w-20 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
)
|
||||||
<TableBody>
|
})}
|
||||||
{grns.map((grn) => {
|
</TableBody>
|
||||||
return (
|
</Table>
|
||||||
<TableRow
|
|
||||||
key={grn.grnId}
|
|
||||||
className="align-top cursor-pointer hover:bg-muted/20"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
|
||||||
className="font-semibold text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{grn.docNo}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground" data-no-row-nav>
|
|
||||||
{grn.poId ? (
|
|
||||||
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
|
|
||||||
PO {String(grn.poId).padStart(2, "0")}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span>Direct</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<GrnStatusBadge status={grn.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell data-no-row-nav className="text-sm text-muted-foreground">
|
|
||||||
{grn.status !== "Confirmed" ? (
|
|
||||||
<span>—</span>
|
|
||||||
) : grn.purchaseInvoiceId ? (
|
|
||||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
|
|
||||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span>Not invoiced</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
|
||||||
<TableCell data-no-row-nav>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`View ${grn.docNo}`}
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center justify-between">
|
||||||
<PaginationFooter
|
<p className="text-sm text-muted-foreground">
|
||||||
pagination={pagination}
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
onPageChange={setPage}
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
</p>
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,22 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react"
|
import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
import { Combobox } from "@/components/ui/combobox"
|
import { Combobox } from "@/components/ui/combobox"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -28,15 +18,9 @@ import { uomsApi } from "@/lib/api/uoms"
|
|||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { PaginationMeta } from "@/types/common"
|
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
|
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
|
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
|
||||||
|
|
||||||
const blankLine = (key: string, warehouseId: number): Line => ({
|
const blankLine = (key: string, warehouseId: number): Line => ({
|
||||||
@@ -58,26 +42,13 @@ export default function BundleTemplatesPage() {
|
|||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [lines, setLines] = useState<Line[]>([])
|
const [lines, setLines] = useState<Line[]>([])
|
||||||
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [formOpen, setFormOpen] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
async function refreshTemplates() {
|
async function refreshTemplates() {
|
||||||
const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
|
const res = await bundleApi.listTemplates({ pageSize: 50 })
|
||||||
setTemplates(res.items)
|
setTemplates(res.items)
|
||||||
setPagination(res.pagination)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,17 +70,6 @@ export default function BundleTemplatesPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false))
|
|
||||||
}, [page, pageSize, query])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [searchInput])
|
|
||||||
|
|
||||||
useEffect(() => setPage(1), [query])
|
|
||||||
|
|
||||||
const itemOptions = useMemo(
|
const itemOptions = useMemo(
|
||||||
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
|
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
|
||||||
[items]
|
[items]
|
||||||
@@ -124,20 +84,18 @@ export default function BundleTemplatesPage() {
|
|||||||
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
|
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setTemplateName("")
|
setTemplateName("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setLines([blankLine("line-1", warehouseId ?? 0)])
|
setLines([blankLine("line-1", warehouseId ?? 0)])
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateForm() {
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFormOpenChange(open: boolean) {
|
|
||||||
setFormOpen(open)
|
|
||||||
if (!open) resetForm()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentSubtotal = useMemo(
|
const componentSubtotal = useMemo(
|
||||||
@@ -166,7 +124,6 @@ export default function BundleTemplatesPage() {
|
|||||||
})
|
})
|
||||||
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
|
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
|
||||||
resetForm()
|
resetForm()
|
||||||
setFormOpen(false)
|
|
||||||
await refreshTemplates()
|
await refreshTemplates()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err))
|
||||||
@@ -191,271 +148,170 @@ export default function BundleTemplatesPage() {
|
|||||||
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it's ready to sell from Create Sale.</p>
|
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it's ready to sell from Create Sale.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
|
<Button size="sm" onClick={submit} disabled={saving}>
|
||||||
<DialogTrigger
|
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"}
|
||||||
render={
|
</Button>
|
||||||
<Button size="lg" onClick={openCreateForm}>
|
</div>
|
||||||
<Plus className="size-5" />
|
|
||||||
New Bundle Template
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>New bundle template</DialogTitle>
|
|
||||||
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
<FieldGroup>
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
<Field>
|
<h2 className="text-sm font-semibold">Bundle details</h2>
|
||||||
<FieldLabel htmlFor="bt-name">Template name</FieldLabel>
|
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p>
|
||||||
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
|
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
</Field>
|
<div className="space-y-1.5">
|
||||||
<Field>
|
<Label className="text-xs">Template name</Label>
|
||||||
<FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
|
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
|
||||||
<Select
|
</div>
|
||||||
value={warehouseId ? String(warehouseId) : ""}
|
<div className="space-y-1.5">
|
||||||
onValueChange={(v) => {
|
<Label className="text-xs">Warehouse</Label>
|
||||||
const nextId = v ? Number(v) : null
|
<Select
|
||||||
setWarehouseId(nextId)
|
value={warehouseId ? String(warehouseId) : ""}
|
||||||
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
|
onValueChange={(v) => {
|
||||||
}}
|
const nextId = v ? Number(v) : null
|
||||||
>
|
setWarehouseId(nextId)
|
||||||
<SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
|
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
|
||||||
<SelectContent>
|
}}
|
||||||
{warehouses.map((w) => (
|
>
|
||||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
<SelectTrigger className="h-9 text-sm">
|
||||||
{w.code} - {w.name}
|
<SelectValue placeholder="Select warehouse" />
|
||||||
</SelectItem>
|
</SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
</SelectContent>
|
{warehouses.map((w) => (
|
||||||
</Select>
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
</Field>
|
{w.code} - {w.name}
|
||||||
<Field>
|
</SelectItem>
|
||||||
<FieldLabel htmlFor="bt-description">Description (optional)</FieldLabel>
|
))}
|
||||||
<Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
|
</SelectContent>
|
||||||
</Field>
|
</Select>
|
||||||
</FieldGroup>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Description (optional)</Label>
|
||||||
|
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{lines.map((line) => (
|
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||||
<FieldGroup key={line.key}>
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<Field>
|
<h2 className="text-sm font-semibold">Component items</h2>
|
||||||
<FieldLabel>Item</FieldLabel>
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add component
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Include</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
<Combobox
|
<Combobox
|
||||||
items={itemOptions}
|
items={itemOptions}
|
||||||
value={line.itemId || null}
|
value={line.itemId || null}
|
||||||
onValueChange={(v) => selectItem(line.key, v ?? 0)}
|
onValueChange={(v) => selectItem(line.key, v ?? 0)}
|
||||||
placeholder="Search item…"
|
placeholder="Search item…"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</TableCell>
|
||||||
<Field>
|
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
|
||||||
<FieldLabel>UOM</FieldLabel>
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
|
</TableCell>
|
||||||
</Field>
|
<TableCell className="px-4 py-2">
|
||||||
<Field>
|
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
<FieldLabel htmlFor={`bt-qty-${line.key}`}>Qty</FieldLabel>
|
</TableCell>
|
||||||
<Input
|
<TableCell className="px-4 py-2">
|
||||||
id={`bt-qty-${line.key}`}
|
<Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" />
|
||||||
type="number"
|
</TableCell>
|
||||||
min="0.0001"
|
<TableCell className="px-4 py-2 text-right">
|
||||||
step="1"
|
|
||||||
value={line.qty}
|
|
||||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
|
||||||
className="font-mono tabular-nums"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`bt-price-${line.key}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={line.unitPrice}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
|
|
||||||
className="font-mono tabular-nums"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Include in bundle</FieldLabel>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-fit rounded-full px-3 py-1 text-xs font-medium",
|
"rounded-full px-3 py-1 text-xs font-medium",
|
||||||
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{line.includeInBundle ? "Included" : "Excluded"}
|
{line.includeInBundle ? "Included" : "Excluded"}
|
||||||
</button>
|
</button>
|
||||||
</Field>
|
</TableCell>
|
||||||
</FieldGroup>
|
<TableCell className="px-4 py-2 text-right">
|
||||||
))}
|
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
|
</Button>
|
||||||
<span className="text-muted-foreground">Component subtotal</span>
|
</TableCell>
|
||||||
<span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
|
</TableRow>
|
||||||
</div>
|
))}
|
||||||
|
</TableBody>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
</Table>
|
||||||
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
|
</div>
|
||||||
Cancel
|
<div className="flex justify-end border-t border-border px-4 py-3 text-sm">
|
||||||
</Button>
|
<span className="text-muted-foreground">Component subtotal</span>
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
|
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
|
||||||
<Save className="size-4" />
|
</div>
|
||||||
{saving ? "Saving…" : "Save bundle template"}
|
</section>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Bundle templates</h3>
|
<h3 className="text-sm font-semibold">Bundle templates</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an "Add bundle" option on Create Sale.</p>
|
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an "Add bundle" option on Create Sale.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshTemplates}>
|
<Button variant="outline" size="sm" onClick={refreshTemplates}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
<Input
|
<Table className="text-sm">
|
||||||
value={searchInput}
|
<TableHeader>
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
<TableRow>
|
||||||
placeholder="Search template name or code…"
|
<TableHead className="h-12 px-4 text-sm">Template</TableHead>
|
||||||
className="mt-3 h-10 w-full max-w-sm text-sm"
|
<TableHead className="h-12 px-4 text-sm">Description</TableHead>
|
||||||
aria-label="Search bundle templates"
|
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
||||||
/>
|
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||||
{templates.length > 0 ? (
|
</TableRow>
|
||||||
<>
|
</TableHeader>
|
||||||
{/* Mobile: stacked cards. */}
|
<TableBody>
|
||||||
<div className="mt-3 flex flex-col gap-3 md:hidden">
|
{templates.length > 0 ? (
|
||||||
{templates.map((t) => (
|
templates.map((t) => (
|
||||||
<div key={t.bundleSaleTemplateId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="min-w-0">
|
<div className="font-medium">{t.templateName}</div>
|
||||||
<p className="truncate font-semibold text-foreground">{t.templateName}</p>
|
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
|
||||||
<p className="text-sm text-muted-foreground">{t.templateCode}</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
|
||||||
<Badge
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
|
||||||
variant="outline"
|
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
|
||||||
className={cn(
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
|
<Link
|
||||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
|
||||||
)}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||||
>
|
>
|
||||||
{t.status}
|
<ShoppingBag className="size-4" />
|
||||||
</Badge>
|
Sell
|
||||||
</div>
|
</Link>
|
||||||
|
</TableCell>
|
||||||
{t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
|
</TableRow>
|
||||||
|
))
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
) : (
|
||||||
<div>
|
<TableRow>
|
||||||
<p className="text-muted-foreground">Lines</p>
|
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
<p className="font-medium text-foreground">{t.lineCount}</p>
|
No bundle templates yet — save one above.
|
||||||
</div>
|
</TableCell>
|
||||||
<div>
|
</TableRow>
|
||||||
<p className="text-muted-foreground">Created</p>
|
|
||||||
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
|
|
||||||
>
|
|
||||||
<ShoppingBag className="size-4" />
|
|
||||||
Sell
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && templates.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
{/* Desktop: table. */}
|
</div>
|
||||||
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-48">Template</TableHead>
|
|
||||||
<TableHead>Description</TableHead>
|
|
||||||
<TableHead className="text-right">Lines</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
<TableHead>Updated</TableHead>
|
|
||||||
<TableHead className="w-32 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{templates.map((t) => (
|
|
||||||
<TableRow key={t.bundleSaleTemplateId} className="align-top hover:bg-muted/20">
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-semibold text-foreground">{t.templateName}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
|
|
||||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
|
||||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{t.updatedAt ? new Date(t.updatedAt).toLocaleString() : "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
|
||||||
>
|
|
||||||
<ShoppingBag className="size-4" />
|
|
||||||
Sell
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
|
||||||
<div className="mt-3 hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={pagination}
|
|
||||||
onPageChange={setPage}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
|
|
||||||
No bundle templates yet — create one above.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -236,45 +236,32 @@ export default function SalesDayEndPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
|
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
|
||||||
{report.itemBreakdown.length === 0 ? (
|
<div className="overflow-x-auto">
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
|
<Table className="text-sm">
|
||||||
) : (
|
<TableHeader>
|
||||||
<>
|
<TableRow>
|
||||||
{/* Mobile: stacked rows. */}
|
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
|
||||||
<div className="flex flex-col gap-1.5 md:hidden">
|
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
|
||||||
{report.itemBreakdown.map((line) => (
|
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
|
||||||
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
|
</TableRow>
|
||||||
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
|
</TableHeader>
|
||||||
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
|
<TableBody>
|
||||||
{line.qty} × {line.revenue.toFixed(2)}
|
{report.itemBreakdown.length === 0 ? (
|
||||||
</span>
|
<TableRow>
|
||||||
</div>
|
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell>
|
||||||
))}
|
</TableRow>
|
||||||
</div>
|
) : (
|
||||||
|
report.itemBreakdown.map((line) => (
|
||||||
{/* Desktop: table. */}
|
<TableRow key={line.itemId}>
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
|
||||||
<Table className="text-sm">
|
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
|
||||||
<TableHeader>
|
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
|
|
||||||
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
|
|
||||||
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))
|
||||||
<TableBody>
|
)}
|
||||||
{report.itemBreakdown.map((line) => (
|
</TableBody>
|
||||||
<TableRow key={line.itemId}>
|
</Table>
|
||||||
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
|
</div>
|
||||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
|
|
||||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.slipNumbers.length > 0 && (
|
{report.slipNumbers.length > 0 && (
|
||||||
|
|||||||
@@ -2,22 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Combobox } from "@/components/ui/combobox"
|
import { Combobox } from "@/components/ui/combobox"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -30,16 +19,10 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { PaginationMeta } from "@/types/common"
|
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { ManagedUser } from "@/types/users"
|
import { ManagedUser } from "@/types/users"
|
||||||
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
|
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
type Line = CreateSalesSlipLineRequest & { key: string }
|
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||||
|
|
||||||
type FreeIssueRow = {
|
type FreeIssueRow = {
|
||||||
@@ -82,25 +65,14 @@ export default function NewFreeIssuePage() {
|
|||||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
const [rows, setRows] = useState<FreeIssueRow[]>([])
|
const [rows, setRows] = useState<FreeIssueRow[]>([])
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [editingRowId, setEditingRowId] = useState<number | null>(null)
|
const [editingRowId, setEditingRowId] = useState<number | null>(null)
|
||||||
const [editingLines, setEditingLines] = useState<Line[]>([])
|
const [editingLines, setEditingLines] = useState<Line[]>([])
|
||||||
const [formOpen, setFormOpen] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRows() {
|
async function refreshRows() {
|
||||||
const list = await salesApi.listFreeIssues({ page, pageSize })
|
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
||||||
setPagination(list.pagination)
|
|
||||||
const details = await Promise.all(
|
const details = await Promise.all(
|
||||||
list.items.map(async (summary) => {
|
list.items.map(async (summary) => {
|
||||||
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
|
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
|
||||||
@@ -154,10 +126,6 @@ export default function NewFreeIssuePage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
|
|
||||||
}, [page, pageSize])
|
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<Line>) {
|
function updateLine(key: string, patch: Partial<Line>) {
|
||||||
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
}
|
}
|
||||||
@@ -166,6 +134,18 @@ export default function NewFreeIssuePage() {
|
|||||||
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllLines() {
|
||||||
|
setLines([blankLine("line-1")])
|
||||||
|
}
|
||||||
|
|
||||||
const itemOptions = useMemo(
|
const itemOptions = useMemo(
|
||||||
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
|
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
|
||||||
[items]
|
[items]
|
||||||
@@ -222,7 +202,6 @@ export default function NewFreeIssuePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLines([blankLine("line-1")])
|
setLines([blankLine("line-1")])
|
||||||
setFormOpen(false)
|
|
||||||
await refreshRows()
|
await refreshRows()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err))
|
||||||
@@ -257,7 +236,6 @@ export default function NewFreeIssuePage() {
|
|||||||
parentLineId: detailLine?.parentLineId ?? null,
|
parentLineId: detailLine?.parentLineId ?? null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
setFormOpen(true)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err))
|
||||||
}
|
}
|
||||||
@@ -268,25 +246,12 @@ export default function NewFreeIssuePage() {
|
|||||||
setEditingLines([])
|
setEditingLines([])
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateForm() {
|
|
||||||
cancelEdit()
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFormOpenChange(open: boolean) {
|
|
||||||
setFormOpen(open)
|
|
||||||
if (!open) cancelEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRow(row: FreeIssueRow) {
|
async function deleteRow(row: FreeIssueRow) {
|
||||||
if (row.status !== "Draft") return
|
if (row.status !== "Draft") return
|
||||||
try {
|
try {
|
||||||
await salesApi.cancelFreeIssue(row.salesSlipId)
|
await salesApi.cancelFreeIssue(row.salesSlipId)
|
||||||
toast.success("Free issue cancelled", row.slipNo)
|
toast.success("Free issue cancelled", row.slipNo)
|
||||||
if (editingRowId === row.salesSlipId) {
|
if (editingRowId === row.salesSlipId) cancelEdit()
|
||||||
cancelEdit()
|
|
||||||
setFormOpen(false)
|
|
||||||
}
|
|
||||||
await refreshRows()
|
await refreshRows()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err))
|
||||||
@@ -311,254 +276,197 @@ export default function NewFreeIssuePage() {
|
|||||||
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
|
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" onClick={submit} disabled={saving}>
|
||||||
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
|
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
|
||||||
<DialogTrigger
|
</Button>
|
||||||
render={
|
|
||||||
<Button size="lg" onClick={openCreateForm}>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
New Free Issue
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>{editingRowId ? "Edit free issue" : "New free issue"}</DialogTitle>
|
|
||||||
<DialogDescription>Pick a customer, warehouse and cashier, then add the item lines.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
|
|
||||||
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="fi-customer">Customer</FieldLabel>
|
|
||||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
|
||||||
<SelectTrigger id="fi-customer" className="w-full"><SelectValue placeholder="Select customer" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="fi-warehouse">Warehouse</FieldLabel>
|
|
||||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
|
|
||||||
<SelectTrigger id="fi-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{warehouses.map((w) => (
|
|
||||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="fi-cashier">Cashier</FieldLabel>
|
|
||||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
|
||||||
<SelectTrigger id="fi-cashier" className="w-full"><SelectValue placeholder="Select cashier" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{users.map((u) => (
|
|
||||||
<SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{activeLines.map((line) => (
|
|
||||||
<FieldGroup key={line.key}>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Item</FieldLabel>
|
|
||||||
<Combobox
|
|
||||||
items={itemOptions}
|
|
||||||
value={line.itemId || null}
|
|
||||||
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
|
|
||||||
placeholder="Search item…"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>UOM</FieldLabel>
|
|
||||||
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`fi-qty-${line.key}`}>Qty</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`fi-qty-${line.key}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
value={line.qty}
|
|
||||||
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { qty: Number(e.target.value) }) : updateLine(line.key, { qty: Number(e.target.value) }))}
|
|
||||||
className="font-mono tabular-nums"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`fi-free-${line.key}`}>Free</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`fi-free-${line.key}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
value={line.freeQty}
|
|
||||||
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { freeQty: Number(e.target.value) }) : updateLine(line.key, { freeQty: Number(e.target.value) }))}
|
|
||||||
className="font-mono tabular-nums"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
|
|
||||||
<Save className="size-4" />
|
|
||||||
{saving ? "Saving…" : editingRowId ? "Update free issue" : "Create free issue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
{editingRowId ? (
|
||||||
|
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-sky-900">Inline edit free issue</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={cancelEdit}>
|
||||||
|
<X className="size-4" /> Cancel edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{activeLines.map((line, idx) => (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
|
<Combobox
|
||||||
|
items={itemOptions}
|
||||||
|
value={line.itemId || null}
|
||||||
|
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)}
|
||||||
|
placeholder="Search item…"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
||||||
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Free issue lines</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAllLines}
|
||||||
|
disabled={lines.length === 1 && !lines[0].itemId}
|
||||||
|
>
|
||||||
|
<Eraser className="size-4" /> Clear all lines
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
|
<Combobox
|
||||||
|
items={itemOptions}
|
||||||
|
value={line.itemId || null}
|
||||||
|
onValueChange={(v) => selectItem(line.key, v ?? 0)}
|
||||||
|
placeholder="Search item…"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-44">
|
||||||
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Created free issues</h3>
|
<h3 className="text-sm font-semibold">Created free issues</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
|
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
|
<Button variant="outline" size="sm" onClick={refreshRows}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
{rows.length > 0 ? (
|
<Table className="text-sm">
|
||||||
<>
|
<TableHeader>
|
||||||
{/* Mobile: stacked cards. */}
|
<TableRow>
|
||||||
<div className="mt-3 flex flex-col gap-3 md:hidden">
|
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead>
|
||||||
{rows.map((row) => (
|
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead>
|
||||||
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableHead className="h-12 px-4 text-sm">Product</TableHead>
|
||||||
<div className="min-w-0">
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
|
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<div className="border-t pt-3 text-sm">
|
<TableBody>
|
||||||
<p className="font-medium text-foreground">{row.itemName}</p>
|
{rows.length > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
|
rows.map((row) => (
|
||||||
</div>
|
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div>
|
||||||
<div>
|
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
|
||||||
<p className="text-muted-foreground">Warehouse</p>
|
</TableCell>
|
||||||
<p className="font-medium text-foreground">{row.warehouseName}</p>
|
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-3.5 min-w-80">
|
||||||
<div>
|
<div className="font-medium">{row.itemName}</div>
|
||||||
<p className="text-muted-foreground">Qty</p>
|
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
|
||||||
<p className="font-medium text-foreground">{row.qty}</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell>
|
||||||
<div>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell>
|
||||||
<p className="text-muted-foreground">Free</p>
|
<TableCell className="px-4 py-3.5">
|
||||||
<p className="font-medium text-foreground">{row.freeQty}</p>
|
<div className="flex justify-end gap-1.5">
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800"
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="mr-1 size-3.5" />
|
||||||
</Button>
|
Edit
|
||||||
{row.status === "Draft" && (
|
</button>
|
||||||
<Button
|
{row.status === "Draft" ? (
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon-sm"
|
type="button"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
onClick={() => deleteRow(row)}
|
||||||
onClick={() => deleteRow(row)}
|
className="inline-flex h-7 items-center rounded-full border border-rose-200 bg-rose-50 px-2 text-xs text-rose-700 hover:bg-rose-100 hover:text-rose-800"
|
||||||
aria-label={`Delete ${row.slipNo}`}
|
>
|
||||||
title="Delete"
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
>
|
Delete
|
||||||
<Trash2 className="size-4" />
|
</button>
|
||||||
</Button>
|
) : null}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
{pagination && rows.length < pagination.totalItems && (
|
<TableRow>
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
No free issues created yet.
|
||||||
</Button>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
{/* Desktop: table. */}
|
</div>
|
||||||
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-48">Promotion</TableHead>
|
|
||||||
<TableHead>Warehouse</TableHead>
|
|
||||||
<TableHead>Product</TableHead>
|
|
||||||
<TableHead className="text-right">Qty</TableHead>
|
|
||||||
<TableHead className="text-right">Free</TableHead>
|
|
||||||
<TableHead className="w-40 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
|
|
||||||
<TableCell className="min-w-80 text-sm text-muted-foreground">
|
|
||||||
<div className="font-medium text-foreground">{row.itemName}</div>
|
|
||||||
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
|
|
||||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{row.status === "Draft" && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => deleteRow(row)}
|
|
||||||
aria-label={`Delete ${row.slipNo}`}
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
|
||||||
<div className="mt-3 hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={pagination}
|
|
||||||
onPageChange={setPage}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
|
|
||||||
No free issues created yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { toast } from "@/components/ui/toast"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||||
@@ -67,7 +66,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const invoiceId = Number(resolvedParams.id)
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
const [printOpen, setPrintOpen] = useState(false)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -204,7 +202,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!customerId || !warehouseId || !etag || !invoice) return
|
if (!customerId || !warehouseId || !etag) return
|
||||||
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
|
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
|
||||||
setError("Select item, UOM and warehouse for every line.")
|
setError("Select item, UOM and warehouse for every line.")
|
||||||
return
|
return
|
||||||
@@ -218,7 +216,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
{
|
{
|
||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
branchId: invoice.branchId,
|
|
||||||
invoiceType,
|
invoiceType,
|
||||||
lines: lines.map((line) => ({
|
lines: lines.map((line) => ({
|
||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
@@ -304,23 +301,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
|
||||||
onClick={() => setPrintOpen(true)}
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
||||||
>
|
>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
Print
|
Print
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePrintDialog
|
|
||||||
invoiceId={invoice.salesInvoiceId}
|
|
||||||
invoiceNo={invoice.invoiceNo}
|
|
||||||
open={printOpen}
|
|
||||||
onOpenChange={setPrintOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
<section className="bg-white print:bg-white">
|
<section className="bg-white print:bg-white">
|
||||||
|
|||||||
@@ -1,27 +1,197 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
import { ArrowLeft, Printer } from "lucide-react"
|
||||||
import { ArrowLeft } from "lucide-react"
|
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { Customer } from "@/types/customers"
|
||||||
import { SalesInvoicePrintPreview } from "@/components/sales/SalesInvoicePrint"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { SalesInvoice } from "@/types/sales"
|
||||||
|
|
||||||
export default function SalesInvoicePrintPage() {
|
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
const invoiceId = Number(params.id)
|
const invoiceId = Number(params.id)
|
||||||
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) {
|
||||||
|
setError(`Invalid invoice id '${params.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.getInvoice(invoiceId),
|
||||||
|
])
|
||||||
|
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setInvoice(doc.data)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [params.id, invoiceId])
|
||||||
|
|
||||||
|
const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice])
|
||||||
|
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
||||||
|
|
||||||
|
if (error && !invoice) {
|
||||||
|
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
||||||
|
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6 print:block print:gap-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-3 print:hidden">
|
||||||
<Link href={`/dashboard/sales/invoices/${params.id}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to invoice">
|
<div className="flex items-center gap-3">
|
||||||
<ArrowLeft className="size-4" />
|
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
</Link>
|
<ArrowLeft className="size-4" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
||||||
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => window.print()}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePrintPreview invoiceId={invoiceId} className="overflow-hidden rounded-2xl border shadow-sm" />
|
<div className="invoice-sheet rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||||
|
<div className="invoice-header mb-6 grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm md:justify-items-end">
|
||||||
|
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||||
|
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
|
||||||
|
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
||||||
|
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||||
|
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
|
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[34%]">Item</TableHead>
|
||||||
|
<TableHead>UOM</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Free</TableHead>
|
||||||
|
<TableHead className="text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="text-right">Discount</TableHead>
|
||||||
|
<TableHead className="text-right">Tax</TableHead>
|
||||||
|
<TableHead className="text-right">Line total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<TableRow key={line.salesInvoiceLineId}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-dashed p-4 print:break-inside-avoid">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
|
||||||
|
<div key={line.salesInvoiceLineId} className="rounded-xl border bg-emerald-500/5 p-3">
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Invoice qty: {line.qty.toFixed(2)}</div>
|
||||||
|
<div className="mt-1 text-sm text-foreground">Free qty: {line.freeQty.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border p-4">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>{invoice.totals.subtotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Discount total</span><span>{invoice.totals.discountTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Free qty total</span><span>{invoice.totals.freeQtyTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Tax total</span><span>{invoice.totals.taxTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between border-t pt-2 text-base font-semibold"><span>Net payable</span><span>{invoice.totals.netPayable.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-muted-foreground"><span>Paid</span><span>{invoice.totals.paidAmount.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-muted-foreground"><span>Balance</span><span>{invoice.totals.balanceAmount.toFixed(2)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,14 +449,12 @@ function NewSalesInvoiceContent() {
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||||
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
|
|
||||||
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
||||||
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
||||||
|
|
||||||
const payload: CreateSalesInvoiceRequest = {
|
const payload: CreateSalesInvoiceRequest = {
|
||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
branchId: effectiveBranchId ?? 0,
|
|
||||||
invoiceType,
|
invoiceType,
|
||||||
lines: lines.map((line) => ({
|
lines: lines.map((line) => ({
|
||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
@@ -603,182 +601,7 @@ function NewSalesInvoiceContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{displayRows.map((row, idx) => {
|
|
||||||
if (row.type === "bundle") {
|
|
||||||
const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
|
|
||||||
const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
|
|
||||||
const bundleQty = row.lines[0]?.bundleQty ?? 1
|
|
||||||
return (
|
|
||||||
<div key={row.groupId} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-foreground">Bundle: {row.bundleName}</p>
|
|
||||||
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
|
||||||
{row.lines.map((l) => (
|
|
||||||
<li key={l.key}>
|
|
||||||
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => removeBundleGroup(row.groupId)}
|
|
||||||
disabled={displayRows.length === 1}
|
|
||||||
aria-label="Remove bundle"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Bundles</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value={bundleQty}
|
|
||||||
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
|
|
||||||
className="h-11 text-sm tabular-nums"
|
|
||||||
aria-label="Bundle quantity"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Free qty</Label>
|
|
||||||
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-3">
|
|
||||||
<span className="text-sm text-muted-foreground">Total</span>
|
|
||||||
<span className="font-semibold tabular-nums">{lkr.format(bundleTotal)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = row.line
|
|
||||||
const { net: lineNet } = lineTotals(line)
|
|
||||||
const scheme = freeIssueSchemeFor(line.itemId)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-sm">Item {idx + 1}</Label>
|
|
||||||
<Combobox
|
|
||||||
items={lineComboOptions}
|
|
||||||
value={line.itemId ? `item:${line.itemId}` : null}
|
|
||||||
onValueChange={(v) => selectLineOption(line.key, v)}
|
|
||||||
placeholder="Search item or bundle…"
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
{(line.batchNo || line.serialNo) && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="mt-6 shrink-0"
|
|
||||||
onClick={() => removeLine(line.key)}
|
|
||||||
disabled={displayRows.length === 1}
|
|
||||||
aria-label="Remove line"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">UOM</Label>
|
|
||||||
<div className="flex h-11 items-center text-sm text-muted-foreground">
|
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Qty</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={line.qty}
|
|
||||||
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
|
|
||||||
className="h-11 text-sm tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Free</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={line.freeQty}
|
|
||||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
|
||||||
className="h-11 text-sm tabular-nums"
|
|
||||||
/>
|
|
||||||
{scheme && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Disc %</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="0.5"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.discountPct}
|
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
|
||||||
className="h-11 text-sm tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm">Unit price</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={line.unitPrice ?? ""}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
|
||||||
className="h-11 text-sm tabular-nums"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
|
||||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
|
||||||
: "No price suggestion available"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-3">
|
|
||||||
<span className="text-sm text-muted-foreground">Line total</span>
|
|
||||||
<span className="font-semibold tabular-nums">{lkr.format(lineNet)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-sm">
|
<Table className="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -801,7 +624,7 @@ function NewSalesInvoiceContent() {
|
|||||||
const bundleQty = row.lines[0]?.bundleQty ?? 1
|
const bundleQty = row.lines[0]?.bundleQty ?? 1
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.groupId}>
|
<TableRow key={row.groupId}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<div className="font-medium">Bundle: {row.bundleName}</div>
|
<div className="font-medium">Bundle: {row.bundleName}</div>
|
||||||
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
||||||
@@ -812,7 +635,7 @@ function NewSalesInvoiceContent() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -825,11 +648,11 @@ function NewSalesInvoiceContent() {
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">
|
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
|
||||||
{bundleFreeQty || "—"}
|
{bundleFreeQty || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -855,7 +678,7 @@ function NewSalesInvoiceContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Combobox
|
<Combobox
|
||||||
items={lineComboOptions}
|
items={lineComboOptions}
|
||||||
@@ -948,31 +771,31 @@ function NewSalesInvoiceContent() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-border pt-4 text-sm sm:flex sm:flex-wrap sm:justify-end sm:gap-x-8 sm:gap-y-1 sm:text-base">
|
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base">
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-sm text-muted-foreground">Gross</span>
|
<span className="text-sm text-muted-foreground">Gross</span>
|
||||||
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
|
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-sm text-muted-foreground">Discount</span>
|
<span className="text-sm text-muted-foreground">Discount</span>
|
||||||
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
|
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-sm text-muted-foreground">Tax</span>
|
<span className="text-sm text-muted-foreground">Tax</span>
|
||||||
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
|
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-muted-foreground">Payable</span>
|
<span className="text-muted-foreground">Payable</span>
|
||||||
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
|
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
|
<Button size="lg" type="button" onClick={submit} disabled={saving}>
|
||||||
{saving ? "Creating…" : "Create Sale"}
|
{saving ? "Creating…" : "Create Sale"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react"
|
||||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -13,15 +12,13 @@ import { useAuth } from "@/components/auth/AuthProvider"
|
|||||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { FilterBar } from "@/components/ui/filter-bar"
|
import { FilterBar } from "@/components/ui/filter-bar"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Field, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { KpiRow } from "@/components/ui/kpi-row"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||||
@@ -31,37 +28,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type StatusFilter = SalesInvoiceStatus | "All"
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
const PAGE_SIZE = 10
|
||||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
}
|
|
||||||
|
|
||||||
type DateRange = "All" | "Today" | "7d" | "30d"
|
|
||||||
|
|
||||||
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
|
|
||||||
{ value: "All", label: "All time", days: null },
|
|
||||||
{ value: "Today", label: "Today", days: 1 },
|
|
||||||
{ value: "7d", label: "7 days", days: 7 },
|
|
||||||
{ value: "30d", label: "30 days", days: 30 },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
|
||||||
function toIsoDate(d: Date) {
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
|
|
||||||
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
|
|
||||||
const days = dateRanges.find((r) => r.value === range)?.days
|
|
||||||
if (!days) return {}
|
|
||||||
const today = new Date()
|
|
||||||
const from = new Date(today)
|
|
||||||
from.setDate(today.getDate() - (days - 1))
|
|
||||||
return { from: toIsoDate(from), to: toIsoDate(today) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
|
|
||||||
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
|
|
||||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -70,7 +37,6 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function SalesInvoicesPage() {
|
export default function SalesInvoicesPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
const stats = useDashboardStats()
|
const stats = useDashboardStats()
|
||||||
|
|
||||||
@@ -78,18 +44,12 @@ export default function SalesInvoicesPage() {
|
|||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("All")
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
// rowsPerPage is the user's choice; pageSize is what's fetched (grows on mobile "Show more").
|
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||||
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
|
|
||||||
|
|
||||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||||
@@ -101,10 +61,7 @@ export default function SalesInvoicesPage() {
|
|||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId])
|
||||||
setPage(1)
|
|
||||||
setPageSize(rowsPerPage)
|
|
||||||
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||||
@@ -116,25 +73,18 @@ export default function SalesInvoicesPage() {
|
|||||||
salesApi
|
salesApi
|
||||||
.listInvoices({
|
.listInvoices({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize: PAGE_SIZE,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
...dateRangeBounds(dateRange),
|
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRows(res.items)
|
setRows(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
}, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + rowsPerPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirm(row: SalesInvoiceSummary) {
|
async function handleConfirm(row: SalesInvoiceSummary) {
|
||||||
setConfirmingId(row.salesInvoiceId)
|
setConfirmingId(row.salesInvoiceId)
|
||||||
@@ -171,14 +121,11 @@ export default function SalesInvoicesPage() {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
[rows, query]
|
[rows, query]
|
||||||
)
|
)
|
||||||
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
const hasFilters = status !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
const printRange = dateRangeBounds(dateRange)
|
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||||
const printHref =
|
|
||||||
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
|
|
||||||
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -187,41 +134,24 @@ export default function SalesInvoicesPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={printHref}
|
href={printHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||||
>
|
>
|
||||||
<Printer className="size-5" />
|
<Printer className="size-5" />
|
||||||
Print batch
|
Print batch
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Sale
|
New Sale
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
|
<KpiRow tiles={stats ? [{ label: "Overdue Invoices", value: stats.overdueSalesInvoices, icon: AlertTriangle }] : null} />
|
||||||
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
{stats ? (
|
|
||||||
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
|
|
||||||
) : (
|
|
||||||
<Skeleton className="h-22.5 rounded-xl" />
|
|
||||||
)}
|
|
||||||
{rows !== null ? (
|
|
||||||
<>
|
|
||||||
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
|
||||||
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
|
|
||||||
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card shadow-sm">
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -256,38 +186,19 @@ export default function SalesInvoicesPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Select<StatusFilter>
|
{tabs.map((t) => (
|
||||||
value={status}
|
<button
|
||||||
onValueChange={(v) => setStatus(v ?? "All")}
|
key={t.value}
|
||||||
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
|
onClick={() => setStatus(t.value)}
|
||||||
>
|
className={cn(
|
||||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
<ListFilter className="size-4 text-muted-foreground" />
|
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||||
<SelectValue />
|
)}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
{t.label}
|
||||||
{tabs.map((t) => (
|
</button>
|
||||||
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
|
))}
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select<DateRange>
|
|
||||||
value={dateRange}
|
|
||||||
onValueChange={(v) => setDateRange(v ?? "All")}
|
|
||||||
items={dateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Date range">
|
|
||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{dateRanges.map((r) => (
|
|
||||||
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -310,99 +221,7 @@ export default function SalesInvoicesPage() {
|
|||||||
|
|
||||||
{!error && rows !== null && visibleRows.length > 0 && (
|
{!error && rows !== null && visibleRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: stacked cards. */}
|
<div className="overflow-x-auto">
|
||||||
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
|
||||||
{visibleRows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.salesInvoiceId}
|
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
||||||
<SalesInvoiceStatusBadge status={row.status} />
|
|
||||||
{row.status !== "Draft" && (
|
|
||||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Date / Due</p>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Lines</p>
|
|
||||||
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Gross / Discount</p>
|
|
||||||
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Net</p>
|
|
||||||
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
{row.status === "Draft" && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={confirmingId === row.salesInvoiceId}
|
|
||||||
onClick={() => handleConfirm(row)}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setPayDialogInvoice(row)
|
|
||||||
setPayDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircleDollarSign className="size-4" />
|
|
||||||
Pay
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pagination && rows !== null && rows.length < pagination.totalItems && (
|
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -420,25 +239,18 @@ export default function SalesInvoicesPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleRows.map((row) => (
|
{visibleRows.map((row) => (
|
||||||
<TableRow
|
<TableRow key={row.salesInvoiceId} className="hover:bg-muted/40">
|
||||||
key={row.salesInvoiceId}
|
|
||||||
className="cursor-pointer hover:bg-muted/40"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className="px-4 py-3.5 font-medium">
|
<TableCell className="px-4 py-3.5 font-medium">
|
||||||
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
||||||
{row.invoiceNo}
|
{row.invoiceNo}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -448,18 +260,8 @@ export default function SalesInvoicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => setPrintInvoice(row)}
|
|
||||||
aria-label={`Print ${row.invoiceNo}`}
|
|
||||||
title="Print"
|
|
||||||
>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -485,6 +287,14 @@ export default function SalesInvoicesPage() {
|
|||||||
Pay
|
Pay
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Show invoice ${row.invoiceNo}`}
|
||||||
|
title="Show invoice"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -493,14 +303,39 @@ export default function SalesInvoicesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
<div className="border-t px-4 py-3">
|
||||||
<div className="hidden border-t px-4 py-4 md:block">
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||||
<PaginationFooter
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
pagination={pagination}
|
<div className="text-muted-foreground">Gross total</div>
|
||||||
onPageChange={setPage}
|
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
</div>
|
||||||
onPageSizeChange={setRowsPerPage}
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
/>
|
<div className="text-muted-foreground">Discount total</div>
|
||||||
|
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Net total</div>
|
||||||
|
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -508,12 +343,6 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
<SalesInvoicePrintDialog
|
|
||||||
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
|
||||||
invoiceNo={printInvoice?.invoiceNo}
|
|
||||||
open={printInvoice !== null}
|
|
||||||
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
import { SalesReturnForm } from "@/components/sales/SalesReturnForm"
|
import { salesReturnsApi } from "@/lib/api/sales-returns"
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { validateSalesReturnLine } from "@/lib/validations/sales"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
|
||||||
|
import { ItemListItem } from "@/types/master-data"
|
||||||
|
import { ReasonCode } from "@/types/stock"
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
interface LineState {
|
||||||
|
selected: boolean
|
||||||
|
qty: string
|
||||||
|
}
|
||||||
|
|
||||||
function NewSalesReturnContent() {
|
function NewSalesReturnContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -12,6 +36,143 @@ function NewSalesReturnContent() {
|
|||||||
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
|
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
|
||||||
const presetLineId = Number(searchParams.get("lineId")) || null
|
const presetLineId = Number(searchParams.get("lineId")) || null
|
||||||
|
|
||||||
|
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
|
||||||
|
const [remainingLoading, setRemainingLoading] = useState(false)
|
||||||
|
|
||||||
|
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
|
||||||
|
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||||
|
const [lineState, setLineState] = useState<Record<number, LineState>>({})
|
||||||
|
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
|
||||||
|
|
||||||
|
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
|
||||||
|
.then(([invoiceList, it, rc]) => {
|
||||||
|
// Only Posted invoices have stock movements to return against.
|
||||||
|
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
|
||||||
|
setItems(it.items)
|
||||||
|
setReasonCodes(rc.items)
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedInvoice) {
|
||||||
|
setLineState({})
|
||||||
|
setRemainingByLine({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next: Record<number, LineState> = {}
|
||||||
|
for (const line of selectedInvoice.lines) {
|
||||||
|
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
|
||||||
|
}
|
||||||
|
setLineState(next)
|
||||||
|
setRemainingLoading(true)
|
||||||
|
salesReturnsApi
|
||||||
|
.getRemaining(selectedInvoice.salesInvoiceId)
|
||||||
|
.then((rows) => {
|
||||||
|
const map: Record<number, number> = {}
|
||||||
|
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
|
||||||
|
setRemainingByLine(map)
|
||||||
|
if (presetLineId) {
|
||||||
|
const remaining = map[presetLineId] ?? 0
|
||||||
|
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
.finally(() => setRemainingLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedInvoice?.salesInvoiceId])
|
||||||
|
|
||||||
|
function itemFor(itemId: number) {
|
||||||
|
return items.find((i) => i.itemId === itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingFor(lineId: number, fallback: number) {
|
||||||
|
return remainingByLine[lineId] ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLine(line: SalesInvoiceLine) {
|
||||||
|
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||||
|
setLineState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQty(lineId: number, qty: string) {
|
||||||
|
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setHeaderError(null)
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
if (!selectedInvoice) {
|
||||||
|
setHeaderError("Select an invoice to return against.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reasonCodeId) {
|
||||||
|
setHeaderError("Select a reason code.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
|
||||||
|
if (selectedLines.length === 0) {
|
||||||
|
setSubmitError("Select at least one line to return.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextErrors: Record<number, Record<string, string>> = {}
|
||||||
|
for (const line of selectedLines) {
|
||||||
|
const errors = validateSalesReturnLine({
|
||||||
|
salesInvoiceLineId: line.salesInvoiceLineId,
|
||||||
|
qty: lineState[line.salesInvoiceLineId].qty,
|
||||||
|
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
|
||||||
|
})
|
||||||
|
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
|
||||||
|
}
|
||||||
|
setLineErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
setSubmitError("Fix the highlighted lines before submitting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
|
||||||
|
salesInvoiceLineId: l.salesInvoiceLineId,
|
||||||
|
itemId: l.itemId,
|
||||||
|
qty: Number(lineState[l.salesInvoiceLineId].qty),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const salesReturn = await salesReturnsApi.create({
|
||||||
|
customerId: selectedInvoice.customerId,
|
||||||
|
warehouseId: selectedInvoice.warehouseId,
|
||||||
|
reasonCodeId,
|
||||||
|
lines: payloadLines,
|
||||||
|
})
|
||||||
|
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
|
||||||
|
router.push("/dashboard/sales/sales-returns")
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err))
|
||||||
|
toast.error("Could not post sales return", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = !invoices
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -21,12 +182,118 @@ function NewSalesReturnContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesReturnForm
|
{loadError && (
|
||||||
presetInvoiceId={presetInvoiceId}
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||||
presetLineId={presetLineId}
|
)}
|
||||||
onCancel={() => router.push("/dashboard/sales/sales-returns")}
|
|
||||||
onSuccess={() => router.push("/dashboard/sales/sales-returns")}
|
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||||
/>
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
|
<Label className="text-base">Sales Invoice</Label>
|
||||||
|
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select a posted invoice" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(invoices ?? []).map((inv) => (
|
||||||
|
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
|
||||||
|
{inv.invoiceNo} — {inv.customerSnapshotName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Reason</Label>
|
||||||
|
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{reasonCodes.map((rc) => (
|
||||||
|
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
|
||||||
|
{rc.description}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headerError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedInvoice && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Invoiced qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Remaining qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{selectedInvoice.lines.map((line) => {
|
||||||
|
const item = itemFor(line.itemId)
|
||||||
|
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
|
||||||
|
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
|
||||||
|
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||||
|
const fullyReturned = !remainingLoading && remaining <= 0
|
||||||
|
return (
|
||||||
|
<TableRow key={line.salesInvoiceLineId}>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
{remainingLoading ? "…" : fullyReturned ? <span className="text-muted-foreground">Fully returned</span> : remaining}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={state.qty}
|
||||||
|
disabled={!state.selected || fullyReturned}
|
||||||
|
aria-invalid={!!errors.qty}
|
||||||
|
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/sales-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? "Posting…" : "Post Return"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,71 +9,32 @@ import { customersApi } from "@/lib/api/customers"
|
|||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { PaginationMeta } from "@/types/common"
|
|
||||||
import { SalesReturnSummary } from "@/types/sales"
|
import { SalesReturnSummary } from "@/types/sales"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
import { ReasonCode } from "@/types/stock"
|
import { ReasonCode } from "@/types/stock"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
|
||||||
const MOBILE_PAGE_SIZE_STEP = 10
|
|
||||||
|
|
||||||
export default function SalesReturnsListPage() {
|
export default function SalesReturnsListPage() {
|
||||||
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
|
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||||
return () => clearTimeout(timeout)
|
.then(([r, c, w, rc]) => {
|
||||||
}, [searchInput])
|
setReturns(r.items)
|
||||||
|
|
||||||
useEffect(() => setPage(1), [query])
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
setError(null)
|
|
||||||
salesReturnsApi
|
|
||||||
.list({ page, pageSize, q: query || undefined })
|
|
||||||
.then((res) => {
|
|
||||||
setReturns(res.items)
|
|
||||||
setPagination(res.pagination)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
.finally(() => setShowMoreLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShowMore() {
|
|
||||||
setShowMoreLoading(true)
|
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [page, pageSize, query])
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
|
||||||
.then(([c, w, rc]) => {
|
|
||||||
setCustomers(c.items)
|
setCustomers(c.items)
|
||||||
setWarehouses(w.items)
|
setWarehouses(w.items)
|
||||||
setReasonCodes(rc.items)
|
setReasonCodes(rc.items)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function customerLabel(id: number) {
|
function customerLabel(id: number) {
|
||||||
@@ -87,8 +48,6 @@ export default function SalesReturnsListPage() {
|
|||||||
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -96,42 +55,20 @@ export default function SalesReturnsListPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
|
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
|
||||||
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
|
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Return
|
New Return
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<Input
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
placeholder="Search doc no., customer…"
|
|
||||||
className="h-12 w-full flex-1 basis-0 text-base"
|
|
||||||
aria-label="Search sales returns"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && returns === null && (
|
{!error && returns === null && (
|
||||||
<div className="overflow-hidden rounded-xl border">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
|
||||||
<Skeleton className="h-3.5 w-16" />
|
|
||||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="ml-4 h-7 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,113 +76,47 @@ export default function SalesReturnsListPage() {
|
|||||||
{!error && returns !== null && returns.length === 0 && (
|
{!error && returns !== null && returns.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<Undo2 className="size-12 text-muted-foreground" />
|
<Undo2 className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">{hasFilters ? "No sales returns match your search." : "No sales returns yet."}</p>
|
<p className="text-base text-muted-foreground">No sales returns yet.</p>
|
||||||
{!hasFilters && (
|
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Plus className="size-5" />
|
||||||
<Plus className="size-5" />
|
New Return
|
||||||
New Return
|
</Link>
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && returns !== null && returns.length > 0 && (
|
{!error && returns !== null && returns.length > 0 && (
|
||||||
<>
|
<Table className="text-base">
|
||||||
{/* Mobile: stacked cards. */}
|
<TableHeader>
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{returns.map((r) => (
|
{returns.map((r) => (
|
||||||
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
<TableRow key={r.returnId}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||||
<div className="min-w-0">
|
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell>
|
||||||
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
|
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
|
||||||
<p className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</p>
|
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||||
</div>
|
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
|
||||||
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
<div>
|
</TableRow>
|
||||||
<p className="text-muted-foreground">Warehouse</p>
|
|
||||||
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Reason</p>
|
|
||||||
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Return qty</p>
|
|
||||||
<p className="font-medium text-foreground">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">GL Journal</p>
|
|
||||||
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">Created</p>
|
|
||||||
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</TableBody>
|
||||||
{pagination && returns.length < pagination.totalItems && (
|
</Table>
|
||||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
|
||||||
{showMoreLoading ? "Loading…" : "Show more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-40">Doc No</TableHead>
|
|
||||||
<TableHead className="w-44">Customer</TableHead>
|
|
||||||
<TableHead className="w-32">Warehouse</TableHead>
|
|
||||||
<TableHead className="w-36">Reason</TableHead>
|
|
||||||
<TableHead className="w-28 text-right">Return Qty</TableHead>
|
|
||||||
<TableHead className="w-28">Status</TableHead>
|
|
||||||
<TableHead className="w-32">GL Journal</TableHead>
|
|
||||||
<TableHead className="w-40">Created</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{returns.map((r) => (
|
|
||||||
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
|
|
||||||
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
|
|
||||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<PaginationFooter
|
|
||||||
pagination={pagination}
|
|
||||||
onPageChange={setPage}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user