Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka c2b92f3086 new updates 2026-09-17 14:22:45 +05:30
171 changed files with 5985 additions and 29731 deletions
-92
View File
@@ -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)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
+2 -14
View File
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
@@ -19,9 +18,8 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -31,14 +29,4 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
-6
View File
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
@@ -12,8 +10,4 @@ public class Uom
{
public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed record GrnDto(
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
@@ -1,18 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
/// <summary>
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
/// computed on every keystroke.
/// </summary>
public sealed record ItemOverviewDto(
int ItemId, string Sku, string Name,
int CategoryId, int? SubCategoryId, int? BrandId,
EntityStatus Status,
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
+1 -7
View File
@@ -1,17 +1,11 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
}
}
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"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null);
});
@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:6701",
"applicationUrl": "http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -16,7 +16,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:6701",
"applicationUrl": "https://localhost:7112;http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
+1 -1
View File
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -15,12 +15,6 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default);
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
}
-80
View File
@@ -1,5 +1,4 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
@@ -36,7 +35,6 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config;
@@ -54,7 +52,6 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches,
IRepository<Serial> serials,
IProductConfigService config,
@@ -71,7 +68,6 @@ public sealed class ItemService : IItemService
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches;
_serials = serials;
_config = config;
@@ -112,82 +108,6 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
var total = await q.CountAsync(ct);
var page = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
.ToListAsync(ct);
var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer, every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
// reduced in memory rather than a "first per group" query EF can't always translate.
var grnCandidates = await _grnLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
.ToListAsync(ct);
var lastPurchaseByItem = grnCandidates
.GroupBy(x => x.ItemId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
// read off the stock ledger — the one place every sale channel posts uniformly.
var cutoff = DateTime.UtcNow.AddDays(-30);
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
var salesByItem = await _stockLedger.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
var rows = page.Select(i =>
{
var stock = stockByItem.GetValueOrDefault(i.ItemId);
var isLowStock = i.ReorderSettings.Any(rs =>
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
return new ItemOverviewDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
salesByItem.GetValueOrDefault(i.ItemId));
}).ToList();
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
+4 -16
View File
@@ -1,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
var uom = new Uom { Name = name };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
+4 -1
View File
@@ -1,8 +1,11 @@
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": ""
}
}
+2 -3
View File
@@ -6,9 +6,8 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
},
"Urls": "http://0.0.0.0:6701",
"Auth": {
"Issuer": "AuthHex",
"Audience": "AuthHexClient",
@@ -17,7 +16,7 @@
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:5602"
},
"FileStorage": {
"RootPath": "App_Data/hr-documents",
-18
View File
@@ -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
+1 -1
View File
@@ -4,4 +4,4 @@
#
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse.
BACKEND_ORIGIN=http://localhost:6701
BACKEND_ORIGIN=http://localhost:5224
-33
View File
@@ -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">{a.bankName ?? a.cashAccountTypeName ?? "—"}</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}`}
</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">
<Tooltip>
<TooltipTrigger
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell>
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
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">
<TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</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">
{b.startChequeNo} – {b.endChequeNo}
</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">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[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">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{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">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[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&apos;s details.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code</Label>
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Type</Label>
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C" className="text-base">B2C — individual</SelectItem>
<SelectItem value="B2B" className="text-base">B2B — business</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Display name (optional)</Label>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 1</Label>
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 2</Label>
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">City</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Country</Label>
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit limit (LKR)</Label>
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit days</Label>
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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>
)
}
+89 -97
View File
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
HelpCircle,
Inbox,
@@ -31,16 +33,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary",
@@ -290,104 +288,98 @@ export default function HelpPage() {
)}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => (
<Link
key={t.ticketId}
href={`/dashboard/help/tickets/${t.ticketId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{isAdmin && (
<div>
<p className="text-muted-foreground">Submitted By</p>
<p className="font-medium text-foreground">{t.createdByName}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Messages</p>
<p className="font-medium text-foreground">{t.messageCount}</p>
</div>
<div>
<p className="text-muted-foreground">Last Updated</p>
<p className="font-medium text-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
</Link>
))}
{totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div>
{/* 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>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((t) => (
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{t.subject}
</Link>
</TableCell>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((t) => (
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{t.subject}
</Link>
</TableCell>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> tickets
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
{isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem>
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
{ticket.messages.map((m) => (
<div
key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`}
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span>
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3}
/>
<Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"}
</Button>
</div>
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
@@ -148,95 +148,47 @@ export default function AttendanceBatchDetailPage() {
</div>
)}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{records.map((r) => (
<div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
<p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">In / Out</p>
<p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Working Hours</p>
<p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
</div>
<div>
<p className="text-muted-foreground">Late</p>
<p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Row status</p>
<p className="font-medium text-foreground">{r.attendanceStatus}</p>
</div>
</div>
{!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-2 border-t pt-3">
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.attendanceRecordId}>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</TableCell>
{!locked && (
<TableCell className="px-3 py-3.5">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</TableCell>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.attendanceRecordId}>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</TableCell>
{!locked && (
<TableCell className="px-3 py-3.5">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Download, Plus, Upload } from "lucide-react"
import Link from "next/link"
import { Download, Eye, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map"
@@ -15,15 +15,11 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
const PAGE_SIZE = 10
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
@@ -33,13 +29,10 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
}
export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("")
@@ -49,17 +42,11 @@ export default function AttendanceBatchesPage() {
function load() {
attendanceApi
.list({ page, pageSize })
.list({ page, pageSize: PAGE_SIZE })
.then((res) => { setBatches(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize])
useEffect(load, [page])
async function handleUpload() {
if (!file) { toast.error("Choose an Excel or CSV file first"); return }
@@ -87,12 +74,12 @@ export default function AttendanceBatchesPage() {
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<div className="flex gap-2">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}>
<Download className="size-4" />Download template
</a>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Upload Attendance</DialogTitle>
@@ -123,83 +110,41 @@ export default function AttendanceBatchesPage() {
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
)}
{!error && batches !== null && batches.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<div
key={b.attendanceUploadBatchId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.docNo}</p>
<p className="text-sm text-muted-foreground">
{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}
</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Rows</p>
<p className="font-medium text-foreground">{b.rowCountTotal}</p>
</div>
<div>
<p className="text-muted-foreground">Duplicates/Errors</p>
<p className="font-medium text-foreground">{b.rowCountDuplicate} / {b.rowCountError}</p>
</div>
</div>
</div>
<TableRow key={b.attendanceUploadBatchId}>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
</TableCell>
</TableRow>
))}
{pagination && batches.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<TableRow
key={b.attendanceUploadBatchId}
className="cursor-pointer hover:bg-muted/40"
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
</TableBody>
</Table>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
</div>
@@ -1,6 +1,6 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
@@ -44,14 +44,6 @@ function money(n: number): string {
}
export default function EmployeeDetailPage() {
return (
<Suspense>
<EmployeeDetailPageContent />
</Suspense>
)
}
function EmployeeDetailPageContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const employeeId = Number(params.id)
@@ -335,7 +327,7 @@ function EmployeeDetailPageContent() {
<button
key={t}
onClick={() => setTab(t)}
className={cn("shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors sm:flex-1", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{t}
</button>
@@ -436,12 +428,12 @@ function EmployeeDetailPageContent() {
</section>
{isEditing && (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" />
Cancel
</Button>
<Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div>
)}
</div>
@@ -460,12 +452,12 @@ function EmployeeDetailPageContent() {
</section>
{isEditing && (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" />
Cancel
</Button>
<Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
</div>
)}
</div>
@@ -493,7 +485,7 @@ function EmployeeDetailPageContent() {
</Field>
<label
className={cn(
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-black px-4 text-sm font-medium sm:w-fit",
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)}
title={uploadTypeId ? undefined : "Select a document type first"}
@@ -510,105 +502,65 @@ function EmployeeDetailPageContent() {
</label>
</div>
</section>
{documents.length === 0 ? (
<div className="rounded-2xl bg-card p-10 text-center text-muted-foreground shadow-sm ring-1 ring-foreground/10">No documents uploaded yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
<p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
</div>
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</div>
<div className="flex items-center justify-between border-t pt-3 text-sm">
<span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
)}
{tab === "Salary & Loans" && (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
@@ -692,63 +644,37 @@ function EmployeeDetailPageContent() {
</div>
)}
{salaryStructures.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
<span className="text-muted-foreground">{s.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Effective to</p>
<p className="font-medium text-foreground">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</p>
</div>
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(s.basicSalary)}</p>
</div>
</div>
</div>
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</section>
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
@@ -784,61 +710,35 @@ function EmployeeDetailPageContent() {
</DialogContent>
</Dialog>
</div>
{loans.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{l.docNo}</span>
<span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Principal</p>
<p className="font-medium text-foreground">{money(l.principalAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Outstanding</p>
<p className="font-medium text-foreground">{money(l.outstandingBalance)}</p>
</div>
</div>
</div>
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</section>
</div>
)}
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
</section>
)}
<div className="sticky bottom-0 -mx-4 flex flex-col-reverse gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:flex-row sm:justify-end sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
<Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
<Save className="size-4" />
{submitting ? "Creating…" : "Create employee"}
</Button>
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -16,10 +15,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
@@ -29,12 +26,8 @@ import { toast } from "@/components/ui/toast"
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -46,7 +39,6 @@ export default function EmployeesPage() {
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
@@ -70,12 +62,6 @@ export default function EmployeesPage() {
})
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
@@ -274,240 +260,159 @@ export default function EmployeesPage() {
)}
{!error && employees !== null && employees.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{employees.map((e) => (
<div
key={e.employeeId}
role="link"
tabIndex={0}
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
onKeyDown={(ev) => {
if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{e.fullName}</p>
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{e.status}
</Badge>
</div>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{e.fullName}
</Link>
</TableCell>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Department</p>
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Designation</p>
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Hire Date</p>
<p className="font-medium text-foreground">
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</p>
</div>
<div>
<p className="text-muted-foreground">Login</p>
{e.hasUserLink ? (
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<p className="font-medium text-muted-foreground">No login</p>
)}
</div>
</div>
</TableCell>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${e.fullName}`}
title={`View ${e.fullName}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${e.fullName}`}
title={`Edit ${e.fullName}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</div>
))}
<TableCell className="px-3 py-4">
{e.hasUserLink ? (
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-xs text-muted-foreground">No login</span>
)}
</TableCell>
{pagination && employees.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow
key={e.employeeId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{e.fullName}
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
{e.hasUserLink ? (
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-xs text-muted-foreground">No login</span>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
</TableCell>
>
{e.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{e.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -18,7 +18,6 @@ import { Badge } from "@/components/ui/badge"
import { KpiRow } from "@/components/ui/kpi-row"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -26,9 +25,6 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
const ALL_STATUS = "__all__"
@@ -51,7 +47,6 @@ export default function LeaveRequestsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
@@ -81,12 +76,6 @@ export default function LeaveRequestsPage() {
})
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -314,155 +303,124 @@ export default function LeaveRequestsPage() {
)}
{!error && requests !== null && requests.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{requests.map((r) => (
<div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
<span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Days</p>
<p className="font-medium text-foreground">{r.daysCount}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Dates</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</p>
</div>
</div>
{r.status === "Submitted" && (
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-success hover:bg-success/10"
aria-label={`Approve ${r.docNo}`}
title={`Approve ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => approve(r.leaveRequestId)}
>
<CheckCircle2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Reject ${r.docNo}`}
title={`Reject ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => openReject(r)}
>
<XCircle className="size-4" />
</Button>
</div>
)}
</div>
))}
{pagination && requests.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
{r.status === "Submitted" ? (
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-success hover:bg-success/10"
aria-label={`Approve ${r.docNo}`}
title={`Approve ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => approve(r.leaveRequestId)}
>
<CheckCircle2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Reject ${r.docNo}`}
title={`Reject ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => openReject(r)}
>
<XCircle className="size-4" />
</Button>
</div>
) : (
<span className="flex justify-center text-xs text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
{r.status === "Submitted" ? (
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-success hover:bg-success/10"
aria-label={`Approve ${r.docNo}`}
title={`Approve ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => approve(r.leaveRequestId)}
>
<CheckCircle2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Reject ${r.docNo}`}
title={`Reject ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => openReject(r)}
>
<XCircle className="size-4" />
</Button>
</div>
) : (
<span className="flex justify-center text-xs text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
</div>
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start justify-between gap-4 border-b border-border pb-5">
<div>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
</div>
<div className="sm:text-right">
<div className="text-right">
<p className="text-sm text-muted-foreground">Period</p>
<p className="font-semibold text-foreground">{period}</p>
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
@@ -1,8 +1,9 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -14,38 +15,18 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** Small tinted stat chip — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null)
@@ -155,11 +136,11 @@ export default function PayrollRunDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
<StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div>
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
</div>
<div className="flex items-center gap-2.5">
@@ -196,146 +177,114 @@ export default function PayrollRunDetailPage() {
</p>
</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<div
key={l.payrollLineId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{l.employeeName}</p>
<span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
</div>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
data-no-row-nav
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(l.basicSalary)}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Allowances</p>
<p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
</div>
<div>
<p className="text-muted-foreground">Deductions</p>
<p className="font-medium text-foreground">{money(totalDeductions)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Net Salary</p>
<p className="font-semibold text-foreground">{money(l.netSalary)}</p>
</div>
</div>
</div>
)
})}
{filteredLines.length > pagedLines.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View breakdown for ${l.employeeName}`}
title="View breakdown"
>
<Eye className="size-4" />
</Link>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow
key={l.payrollLineId}
className="cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium text-foreground">
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<TableCell className="px-4 py-3.5" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</>
</div>
)}
{filteredLines.length > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Plus } from "lucide-react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -10,21 +10,17 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
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 { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
const ALL_STATUS = "__all__"
@@ -35,14 +31,12 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
}
export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const now = new Date()
@@ -55,12 +49,6 @@ export default function PayrollRunsPage() {
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -153,99 +141,102 @@ export default function PayrollRunsPage() {
)}
{!error && runs !== null && runs.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{runs.map((r) => (
<div
key={r.payrollRunId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Employees</p>
<p className="font-medium text-foreground">{r.employeeCount}</p>
</div>
<div>
<p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
</div>
</div>
))}
{pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${r.docNo}`}
title={`View ${r.docNo}`}
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow
key={r.payrollRunId}
className="cursor-pointer hover:bg-muted/20"
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -15,7 +15,6 @@ 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"
@@ -23,9 +22,6 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null)
@@ -37,7 +33,6 @@ export default function DepartmentsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -64,12 +59,6 @@ export default function DepartmentsPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -295,9 +284,9 @@ export default function DepartmentsPage() {
</Select>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -366,209 +355,151 @@ export default function DepartmentsPage() {
)}
{!error && items !== null && items.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((d) => (
<div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.name}</p>
<span className="font-mono text-xs text-muted-foreground">{d.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{d.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Parent</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((d) => (
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${d.name}`}
title={`Edit ${d.name}`}
onClick={() => openEdit(d)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
d.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)}
>
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${d.name}`}
title={`Delete ${d.name}`}
disabled={actionPendingId === d.departmentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${d.name}?`}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((d) => (
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
aria-label={`Edit ${d.name}`}
title={`Edit ${d.name}`}
onClick={() => openEdit(d)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${d.name}`}
title={`Edit ${d.name}`}
onClick={() => openEdit(d)}
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
d.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)}
>
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${d.name}`}
title={`Delete ${d.name}`}
disabled={actionPendingId === d.departmentId}
/>
}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
d.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)}
>
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${d.name}`}
title={`Delete ${d.name}`}
disabled={actionPendingId === d.departmentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${d.name}?`}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${d.name}?`}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -16,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -24,9 +23,6 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null)
@@ -37,7 +33,6 @@ export default function DocumentTypesPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -63,12 +58,6 @@ export default function DocumentTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -271,9 +260,9 @@ export default function DocumentTypesPage() {
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -342,205 +331,149 @@ export default function DocumentTypesPage() {
)}
{!error && items !== null && items.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Category</p>
<p className="font-medium text-foreground">{t.category}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
/>
}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -16,15 +16,11 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -35,7 +31,6 @@ export default function LeaveTypesPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -64,12 +59,6 @@ export default function LeaveTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -289,9 +278,9 @@ export default function LeaveTypesPage() {
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -361,211 +350,151 @@ export default function LeaveTypesPage() {
)}
{!error && items !== null && items.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Days/yr</p>
<p className="font-medium text-foreground">{t.accrualPerYear}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
/>
}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -36,7 +32,6 @@ export default function SalaryComponentsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -61,12 +56,6 @@ export default function SalaryComponentsPage() {
.list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -264,9 +253,9 @@ export default function SalaryComponentsPage() {
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -335,205 +324,149 @@ export default function SalaryComponentsPage() {
)}
{!error && items !== null && items.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((c) => (
<div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{c.name}</p>
<span className="font-mono text-xs text-muted-foreground">{c.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.componentType}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${c.name}`}
title={`Delete ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${c.name}`}
title={`Delete ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
/>
}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${c.name}`}
title={`Delete ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -141,66 +141,34 @@ export default function StatutorySettingsPage() {
)}
{settings !== null && settings.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{settings.map((s) => (
<div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<p className="font-semibold text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">EPF (Employee)</p>
<p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">EPF (Employer)</p>
<p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">ETF (Employer)</p>
<p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">OT multiplier</p>
<p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
</section>
@@ -257,46 +225,28 @@ export default function StatutorySettingsPage() {
)}
{slabs !== null && slabs.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{slabs.map((s) => (
<div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
<p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
</div>
<p className="border-t pt-3 text-sm text-muted-foreground">
{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
</p>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slabs.map((s) => (
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{slabs.map((s) => (
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
</section>
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -16,16 +16,12 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function maskToDays(mask: number): boolean[] {
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
@@ -40,7 +36,6 @@ export default function WorkShiftsPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -79,12 +74,6 @@ export default function WorkShiftsPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -228,7 +217,7 @@ export default function WorkShiftsPage() {
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
@@ -297,7 +286,7 @@ export default function WorkShiftsPage() {
</div>
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
@@ -366,9 +355,9 @@ export default function WorkShiftsPage() {
</div>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
@@ -437,205 +426,149 @@ export default function WorkShiftsPage() {
)}
{!error && items !== null && items.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((w) => (
<div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{w.name}</p>
<span className="font-mono text-xs text-muted-foreground">{w.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{w.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Hours</p>
<p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((w) => (
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{w.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.name}`}
title={`Delete ${w.name}`}
disabled={actionPendingId === w.workShiftId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((w) => (
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
<TableCell className="px-4 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
>
{w.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.name}`}
title={`Delete ${w.name}`}
disabled={actionPendingId === w.workShiftId}
/>
}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.name}`}
title={`Delete ${w.name}`}
disabled={actionPendingId === w.workShiftId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4">
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader
title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
)}
{!error && report !== null && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
@@ -97,38 +97,36 @@ export default function TrialBalancePage() {
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
+87 -150
View File
@@ -4,10 +4,9 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign,
Boxes,
Clock,
ClipboardList,
ListChecks,
PackageCheck,
@@ -129,7 +128,7 @@ export default function DashboardPage() {
</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 ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
@@ -138,6 +137,9 @@ export default function DashboardPage() {
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link>
@@ -152,68 +154,66 @@ export default function DashboardPage() {
</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 className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
</Link>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-64 sm:h-72">
<LineChart
labels={movementTrend.labels}
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]}
/>
</div>
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
@@ -229,111 +229,48 @@ export default function DashboardPage() {
{loaded ? (
movements.length > 0 ? (
<>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
</div>
</div>
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
</TableBody>
</Table>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
{invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" />
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<section className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="border-b pb-4">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div>
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{line.grnDocNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
/>
</TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
<Button size="lg" type="button" onClick={submit} disabled={saving}>
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button>
</div>
@@ -2,33 +2,28 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { dashboardApi } from "@/lib/api/dashboard"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -37,7 +32,6 @@ const tabs: { value: StatusFilter; label: string }[] = [
]
export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
@@ -50,15 +44,6 @@ export default function PurchaseInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId })
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
.catch(() => setMonthlyPurchases([]))
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -154,30 +139,6 @@ export default function PurchaseInvoicesPage() {
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
</div>
{monthlyPurchases === null ? (
<Skeleton className="h-64 rounded-lg sm:h-72" />
) : monthlyPurchases.length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={monthlyPurchases.map((m) =>
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
)}
datasets={[
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
@@ -228,99 +189,7 @@ export default function PurchaseInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="flex flex-col gap-3 p-4 md:hidden">
{visibleRows.map((row) => (
<div
key={row.purchaseInvoiceId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
data-no-row-nav
className="font-semibold text-foreground hover:underline"
>
{row.docNo}
</Link>
<p className="truncate text-sm text-muted-foreground">{row.vendorSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Vendor invoice no.</p>
<p className="font-medium text-foreground">{row.vendorInvoiceNo}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</p>
</div>
<div>
<p className="text-muted-foreground">GRNs</p>
<p className="font-medium text-foreground tabular-nums">{row.grnCount}</p>
</div>
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-semibold text-foreground tabular-nums">{row.totals.grandTotal.toFixed(2)}</p>
</div>
{isAdmin && branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
</div>
)}
</div>
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.purchaseInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)}
</div>
))}
</div>
<div className="hidden overflow-x-auto md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -339,14 +208,7 @@ export default function PurchaseInvoicesPage() {
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow
key={row.purchaseInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
>
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo}
@@ -354,10 +216,10 @@ export default function PurchaseInvoicesPage() {
</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-4 py-3.5">
@@ -372,7 +234,7 @@ export default function PurchaseInvoicesPage() {
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<TableCell className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
@@ -399,6 +261,14 @@ export default function PurchaseInvoicesPage() {
Pay
</Button>
)}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -424,9 +294,22 @@ export default function PurchaseInvoicesPage() {
</div>
</div>
{pagination && (
<div className="border-t px-4 py-4">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true)
try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
setPo(result.data)
setEtag(result.etag)
setLines(toDraftLines(result.data))
@@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex flex-wrap items-center gap-3">
{po.status === "Draft" && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" />
{submitting ? "Submitting…" : "Submit for approval"}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
>
<Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"}
@@ -348,11 +348,11 @@ export default function PurchaseOrderDetailPage() {
)}
{po.status === "PendingApproval" && isAdmin && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" />
{approving ? "Approving…" : "Approve"}
</Button>
<Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<X className="size-5" />
Reject
</Button>
@@ -362,7 +362,6 @@ export default function PurchaseOrderDetailPage() {
<Button
variant="destructive"
size="lg"
className="w-full sm:w-auto"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
@@ -381,13 +380,13 @@ export default function PurchaseOrderDetailPage() {
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Reason (e.g. Duplicate order)"
className="h-11 w-full text-base sm:max-w-md"
className="h-11 max-w-md text-base"
/>
<div className="flex flex-col-reverse gap-3 sm:flex-row">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back
</Button>
<Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Confirm cancellation"}
</Button>
</div>
@@ -438,7 +437,7 @@ export default function PurchaseOrderDetailPage() {
)}
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
@@ -448,148 +447,7 @@ export default function PurchaseOrderDetailPage() {
)}
</div>
{/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
if (!editable) {
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Qty / Received</p>
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
</div>
<div>
<p className="text-muted-foreground">Unit price / Tax</p>
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
</div>
</div>
</div>
)
}
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<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">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -716,11 +574,11 @@ export default function PurchaseOrderDetailPage() {
</div>
{editable && (
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back
</Button>
<Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Button size="lg" onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
@@ -2,8 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -35,7 +34,6 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/
export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -158,14 +156,7 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => {
const vendor = vendorsById.get(po.vendorId)
return (
<TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableRow key={po.poId} className="align-top">
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
@@ -182,7 +173,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()}
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
type="button"
@@ -204,6 +195,14 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" />
Reject
</Button>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -10,7 +10,6 @@ import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
@@ -20,9 +19,7 @@ import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
@@ -69,14 +66,11 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -217,21 +211,6 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
// the dropdown only appears when the header itself is on "All Branches", so there's no
// branch to pin to and the Admin must pick one explicitly for this PO.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -252,10 +231,6 @@ function NewPurchaseOrderContent() {
setHeaderError("Select a vendor.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this purchase order.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
@@ -293,7 +268,6 @@ function NewPurchaseOrderContent() {
requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines,
saveAsDraft,
branchId: effectiveBranchId ?? 0,
})
toast.success(
"Purchase order created",
@@ -395,30 +369,6 @@ function NewPurchaseOrderContent() {
</Dialog>
</div>
</div>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
{requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
)}
@@ -430,9 +380,9 @@ function NewPurchaseOrderContent() {
)}
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Link
href="/dashboard/products/new"
target="_blank"
@@ -451,21 +401,27 @@ function NewPurchaseOrderContent() {
</div>
{lines.length > 0 && (
<>
{/* Mobile: stacked editable cards, one per line. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId || rfqId ? (
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<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]} />
</>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Warehouse</Label>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
@@ -522,144 +463,42 @@ function NewPurchaseOrderContent() {
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<Label className="text-sm">Unit price</Label>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</div>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-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>
</>
</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>
@@ -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="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"}
</Button>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"}
</Button>
</div>
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -30,13 +28,9 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const PAGE_SIZE = 10
export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -49,10 +43,8 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -64,21 +56,15 @@ export default function PurchaseOrdersListPage() {
function load() {
setError(null)
purchaseOrdersApi
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => {
setPos(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status])
useEffect(load, [page, query, status])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
@@ -135,15 +121,12 @@ export default function PurchaseOrdersListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link
href="/dashboard/procurement/purchase-orders/approvals"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Clock className="size-5" />
Approvals queue
</Link>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New PO
</Link>
@@ -187,21 +170,9 @@ export default function PurchaseOrdersListPage() {
)}
{!error && pos === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
@@ -215,171 +186,45 @@ export default function PurchaseOrdersListPage() {
{!error && visiblePos !== null && visiblePos.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<div
key={po.poId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
</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>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell>
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
<TableRow key={po.poId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell>
)}
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View purchase order"
aria-label="View"
title="View"
>
<Eye className="size-4" />
@@ -451,17 +296,30 @@ export default function PurchaseOrdersListPage() {
})}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
@@ -210,99 +210,49 @@ function NewPurchaseReturnContent() {
{selectedGrn && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<label className="flex items-start gap-3">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
<span className="min-w-0 truncate text-base font-medium text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</span>
</label>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Received qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Hold status</p>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</div>
</div>
<div>
<Label className="text-sm">Return qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
@@ -310,11 +260,11 @@ function NewPurchaseReturnContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
@@ -9,70 +9,31 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { PurchaseReturnSummary } from "@/types/procurement"
import { Vendor, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseReturnsListPage() {
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
purchaseReturnsApi
.list({ page, pageSize, q: query || undefined })
.then((res) => {
setReturns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query])
useEffect(() => {
Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([v, w, rc]) => {
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([r, v, w, rc]) => {
setReturns(r.items)
setVendors(v.items)
setWarehouses(w.items)
setReasonCodes(rc.items)
})
.catch(() => {})
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorCode(id: number) {
@@ -85,8 +46,6 @@ export default function PurchaseReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
}
const hasFilters = query.length > 0
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -94,42 +53,20 @@ export default function PurchaseReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
</div>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., vendor…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search purchase returns"
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && returns === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
@@ -137,107 +74,45 @@ export default function PurchaseReturnsListPage() {
{!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageX className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
{!hasFilters && (
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
)}
<p className="text-base text-muted-foreground">No purchase returns yet.</p>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
)}
{!error && returns !== null && returns.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
</div>
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
<TableRow key={r.returnId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
{pagination && returns !== null && returns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>GL Journal</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
</TableBody>
</Table>
)}
</div>
)
@@ -7,19 +7,15 @@ import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
@@ -44,7 +40,6 @@ function emptyLine(): DraftLine {
export default function NewRequisitionPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -52,25 +47,11 @@ export default function NewRequisitionPage() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -86,10 +67,6 @@ export default function NewRequisitionPage() {
setSubmitError("Add at least one line.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this requisition.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
@@ -110,7 +87,7 @@ export default function NewRequisitionPage() {
setSubmitting(true)
try {
const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
const requisition = await requisitionsApi.create({ lines: payloadLines })
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) {
@@ -140,30 +117,6 @@ export default function NewRequisitionPage() {
{!loading && (
<>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -14,9 +14,8 @@ import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -24,7 +23,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
export default function RequisitionsListPage() {
const stats = useDashboardStats()
@@ -153,7 +152,29 @@ export default function RequisitionsListPage() {
</TableBody>
</Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
@@ -9,7 +9,6 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
@@ -17,7 +16,6 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -36,10 +34,6 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
// The produced list comes back whole (no server paging), so pages are sliced client-side from
// the already-loaded lines. The summary totals still cover the whole filtered list.
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function ProducedItemsPage() {
const router = useRouter()
@@ -55,8 +49,6 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => {
setLoadError(null)
@@ -68,11 +60,7 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
.then((res) => {
setData(res)
// A new filter is a new list — start back at the first page.
setPage(1)
})
.then(setData)
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
@@ -92,37 +80,26 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
const totalItems = data?.items.length ?? 0
const pagination: PaginationMeta = {
page,
pageSize,
totalItems,
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
}
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-sm text-muted-foreground sm:text-base">
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
<p className="text-base text-muted-foreground">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p>
</div>
{/* Two-up on phones (date range side by side, then template/warehouse), status full width —
five stacked labelled controls would push the results well below the fold. */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">Template</Label>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" />
@@ -134,8 +111,8 @@ export default function ProducedItemsPage() {
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">Warehouse</Label>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" />
@@ -147,8 +124,8 @@ export default function ProducedItemsPage() {
</SelectContent>
</Select>
</div>
<div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-sm sm:text-base">Status</Label>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
@@ -198,148 +175,67 @@ export default function ProducedItemsPage() {
</p>
</div>
) : (
<>
{/* Mobile: totals first (the figure people come here for), then one card per line. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm font-semibold text-foreground">
Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
</p>
<p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
<p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
<div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Produced</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Scrapped</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Good</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
</div>
</div>
</div>
{pageItems.map((line) => (
<button
key={line.runId}
type="button"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
>
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{line.docNo}</p>
<p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</div>
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Produced</p>
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Scrapped</p>
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Good</p>
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Cost of production</p>
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
</div>
</div>
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
{line.templateName} · {line.warehouseName}
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
</p>
</button>
))}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
@@ -140,7 +140,7 @@ export function RunActions({
return (
<>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" />
Return leftover
@@ -19,7 +19,6 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import {
RunStageCardBody,
RunHeaderNodeComponent,
RunStageNodeComponent,
type RunHeaderData,
@@ -51,75 +50,7 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/
const HEADER_GAP_X = 240
/** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
return new Map(
run.stages.map((stage) => {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
return [
stage.runStageId,
{
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
]
})
)
}
/**
* Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
* (left to right, then top to bottom) so parallel branches read the way they were drawn.
*/
function flowOrder(run: ProductionRunGraph): RunStage[] {
const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
const ordered: RunStage[] = []
while (ready.length > 0) {
ready.sort(byPosition)
const stage = ready.shift()!
ordered.push(stage)
for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
inDegree.set(e.childRunStageId, next)
if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
}
}
// The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
const seen = new Set(ordered.map((s) => s.runStageId))
return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
}
function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>): { nodes: Node[]; edges: Edge[] } {
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
@@ -133,12 +64,42 @@ function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>
},
]
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({
id: String(stage.runStageId),
type: "runStage",
position: { x: stage.posX, y: stage.posY },
data: stageData.get(stage.runStageId)!,
data: {
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
draggable: false,
})
}
@@ -212,12 +173,10 @@ export default function ProductionRunDetailPage() {
})
}, [])
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
const { nodes, edges } = useMemo(
() => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run, stageData]
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run]
)
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -254,9 +213,9 @@ export default function ProductionRunDetailPage() {
if (!run) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-full max-w-64" />
<Skeleton className="h-10 w-64" />
<Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
<Skeleton className="h-[45vh] w-full rounded-2xl" />
</div>
)
}
@@ -333,38 +292,9 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend />
</div>
<p className="text-sm text-muted-foreground">
<span className="md:hidden">Tap a stage to open it.</span>
<span className="hidden md:inline">Click a stage to open it.</span>
</p>
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
{/* Phones get the stages as a list in flow order — the operator on the floor needs to
reach "their" stage in one tap, not pan and pinch around a canvas. */}
<ol className="flex flex-col gap-3 md:hidden">
{orderedStages.map((stage, i) => {
const data = stageData.get(stage.runStageId)!
return (
<li key={stage.runStageId} className="flex gap-3">
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
{i + 1}
</span>
<button
type="button"
onClick={() => setSelectedStageId(stage.runStageId)}
className={cn(
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<RunStageCardBody data={data} />
</button>
</li>
)
})}
</ol>
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
@@ -19,16 +19,12 @@ import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
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"
@@ -60,8 +56,6 @@ export default function ProductionRunsPage() {
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -90,7 +84,7 @@ export default function ProductionRunsPage() {
productionRunsApi
.list({
page,
pageSize,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
templateId: templateId ?? undefined,
@@ -104,16 +98,10 @@ export default function ProductionRunsPage() {
setRuns([])
setLoadError(errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status, templateId, warehouseId])
}, [page, query, status, templateId, warehouseId])
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
// 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).
@@ -183,11 +171,11 @@ export default function ProductionRunsPage() {
<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-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
<p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
</div>
<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">
<DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle>
@@ -277,59 +265,54 @@ export default function ProductionRunsPage() {
</Dialog>
</div>
{/* Search full width, then the three filters as a compact grid on phones (four stacked
56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
lifts the selects into the outer flex row. */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative lg:flex-1 lg:basis-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…"
className="h-12 w-full pl-11 text-base sm:h-14"
className="h-14 w-full pl-11 text-base"
aria-label="Search runs"
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
<Select<number | null>
value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
>
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
>
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<Select<number | null>
value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<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="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{hasFilters && (
@@ -442,15 +425,20 @@ export default function ProductionRunsPage() {
})}
</div>
{pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<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>
)}
</>
@@ -233,9 +233,7 @@ export function StageEditorPanel({
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
{/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Stage editor</DialogTitle>
</DialogHeader>
@@ -303,7 +301,7 @@ export function StageEditorPanel({
</SelectContent>
</Select>
{!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" />
</button>
)}
@@ -406,7 +404,7 @@ export function StageEditorPanel({
/>
)}
{!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" />
</button>
)}
@@ -464,7 +462,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm"
/>
{!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" />
</button>
)}
@@ -24,15 +24,14 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
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-4! md:size-2.5!" />
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
{selected && data.onDelete && (
<button
type="button"
aria-label="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) => {
e.stopPropagation()
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>}
<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>
)
}
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) {
return (
<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" />
</div>
)
@@ -661,15 +661,12 @@ function TemplateBuilderContent() {
}
return (
// Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
// Only from lg up does the builder fill the viewport with the canvas taking the remainder —
// on a short screen the fields and issue list would otherwise squeeze it to nothing.
<div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
{graph ? (
<Badge
variant="outline"
@@ -686,17 +683,16 @@ function TemplateBuilderContent() {
</Badge>
)}
</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
{edges.length === 1 ? "" : "s"}
</p>
</div>
</div>
{/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
{!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" />
Add Stage
</Button>
@@ -714,7 +710,6 @@ function TemplateBuilderContent() {
<Button
type="button"
variant={graph.status === "Active" ? "destructive" : "success"}
className={cn(locked && "col-span-2 sm:col-span-1")}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
@@ -722,7 +717,7 @@ function TemplateBuilderContent() {
</Button>
)}
{!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" />
{saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button>
@@ -730,7 +725,7 @@ function TemplateBuilderContent() {
</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>
<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" />
@@ -739,7 +734,7 @@ function TemplateBuilderContent() {
<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" />
</Field>
<Field className="sm:col-span-2 lg:col-span-1">
<Field>
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input
id="tpl-description"
@@ -791,7 +786,7 @@ function TemplateBuilderContent() {
</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 && (
<ReactFlow
nodes={displayNodes}
@@ -811,8 +806,7 @@ function TemplateBuilderContent() {
>
<Background />
<Controls showInteractive={!locked} />
{/* Covers too much of a phone-sized canvas to be worth it. */}
<MiniMap pannable zoomable className="hidden! md:block!" />
<MiniMap pannable zoomable />
</ReactFlow>
)}
</div>
@@ -1,7 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
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 { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common"
import {
@@ -24,16 +22,12 @@ import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All"
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 }
@@ -97,8 +91,6 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -126,7 +118,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi
.list({
page,
pageSize,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
})
@@ -138,16 +130,10 @@ export default function ProductionTemplatesPage() {
setTemplates([])
setLoadError(errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status])
}, [page, query, status])
useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = query.length > 0 || status !== "All"
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-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">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>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
</div>
<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">
<DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle>
@@ -266,7 +252,7 @@ export default function ProductionTemplatesPage() {
)}
{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 ? (
<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" />
@@ -276,62 +262,7 @@ export default function ProductionTemplatesPage() {
</div>
) : (
<>
{/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
narrow touch screen, so each line reads top to bottom instead. */}
<div className="flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<Link
key={t.templateId}
href={`/dashboard/production/templates/${t.templateId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
<p className="truncate font-semibold text-foreground">{t.name}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</span>
{t.activeRunCount > 0 && (
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
{t.activeRunCount} in progress
</span>
)}
</div>
</div>
{t.stageNames.length > 0 ? (
<ol className="flex flex-col gap-1.5 border-t pt-3">
{t.stageNames.map((stageName, i) => (
<li key={i} className="flex items-center gap-2.5 text-sm">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
{i + 1}
</span>
<span className="truncate text-foreground">{stageName}</span>
</li>
))}
</ol>
) : (
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
)}
</Link>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
@@ -355,9 +286,24 @@ export default function ProductionTemplatesPage() {
)}
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-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>
)}
</>
@@ -1,69 +1,36 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
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 { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
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. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
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 { 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 { 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 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() {
const params = useParams<{ id: string }>()
const router = useRouter()
const itemId = Number(params.id)
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 [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
const [sku, setSku] = useState("")
const [barcode, setBarcode] = useState("")
@@ -114,7 +77,6 @@ export default function ItemDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
function applyItem(data: Item) {
setItem(data)
@@ -165,97 +127,6 @@ export default function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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() {
if (!item || !etag) return
setSaveError(null)
@@ -281,7 +152,6 @@ export default function ItemDetailPage() {
)
applyItem(result.data)
setEtag(result.etag)
setEditOpen(false)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -289,7 +159,6 @@ export default function ItemDetailPage() {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
@@ -343,19 +212,8 @@ export default function ItemDetailPage() {
return (
<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 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 className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -370,26 +228,9 @@ export default function ItemDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Button size="lg" variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
label="Stock value"
value={stockValue === null ? "…" : `LKR ${stockValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
/>
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
{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="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<TrendingUp className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Total stock variation</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : stockTrend.length > 1 ? (
<LineChart
labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[
{
label: `On hand (${uomName(item.baseUomId)})`,
data: stockTrend.map((p) => p.qty),
borderColor: "rgba(225,29,72,1)",
backgroundColor: "rgba(225,29,72,0.15)",
},
]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<ShoppingCart className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Monthly purchases vs sales</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : monthlyActivity.length > 0 ? (
<BarChart
// Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
// activity, Chart.js would otherwise center the lone category across the full
// width. Trailing blank slots keep the axis/gridlines spanning the full card
// while the real bars sit at the start. Skipped on mobile — there the bars
// should stretch to fill the narrower card instead.
labels={[
...monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill("")),
]}
datasets={[
{
label: "Purchased",
data: [
...monthlyActivity.map(([, v]) => v.in),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
{
label: "Sold",
data: [
...monthlyActivity.map(([, v]) => v.out),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent sales</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentSales.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
{href ? (
<Link href={href} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
<span className="truncate font-medium text-foreground">{docLabel(row.sourceDocType, row.sourceDocId)}</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
{href ? (
<Link href={href} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
docLabel(row.sourceDocType, row.sourceDocId)
)}
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent GRNs (purchases)</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentGrns.map((row) => (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentGrns.map((row) => (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {item.sku}</DialogTitle>
<DialogDescription>Update this item&apos;s basic info, pricing and classification.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Basic info</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
@@ -828,17 +465,16 @@ export default function ItemDetailPage() {
</Select>
</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 className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}>
Back
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
<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).
@@ -1,201 +1,223 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
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 { Button } from "@/components/ui/button"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
const PAGE_SIZE = 5
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 [pagination, setPagination] = useState<PaginationMeta | 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 [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, status])
function load() {
setError(null)
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
})
.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)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
let result
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("")
setEditing(null)
setErrors({})
load()
} catch (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 {
setSubmitting(false)
}
}
/** 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"
setTogglingId(brand.brandId)
setDeletingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setTogglingId(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)
setDeletingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
<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">Brands</h1>
<p className="text-base text-muted-foreground">Manage product brands.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<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>
{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>
)}
{!error && brands === 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 className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
@@ -203,173 +225,162 @@ function BrandPanel() {
{!error && brands !== null && brands.length === 0 && (
<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" />
<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>
)}
{!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) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
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={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
className="shrink-0"
/>
<span className="truncate font-medium">{b.name}</span>
</label>
))}
</div>
)}
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
</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>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</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}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.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={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</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>
)
}
function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
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)
}
}
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</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
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
{!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>
</button>
)
}
@@ -166,139 +166,71 @@ export default function CategorySubCategoriesPage() {
)}
{!error && subCategories !== null && subCategories.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">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) => (
<div key={s.subCategoryId} 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-medium text-foreground">{s.name}</p>
<p className="text-sm text-muted-foreground">#{s.subCategoryId}</p>
</div>
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{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(
"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}
</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">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(s.createdAt).toLocaleDateString()}</p>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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>
</div>
<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>
))}
</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">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>
</>
</TableBody>
</Table>
)}
</div>
)
@@ -2,15 +2,14 @@
import { useEffect, useState } from "react"
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 { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
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 { Badge } from "@/components/ui/badge"
@@ -18,7 +17,6 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
@@ -28,7 +26,7 @@ type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 5
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[] | null>(null)
@@ -50,36 +48,6 @@ export default function CategoriesPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
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(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
@@ -264,187 +232,128 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{sortedCategories!.map((c) => (
<div key={c.categoryId} 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-medium text-foreground">{c.name}</p>
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
{config?.subcategoriesEnabled && (
<div>
<p className="text-muted-foreground">Subcategories</p>
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
</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>
<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>
<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 text-muted-foreground">#{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"
)}
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree. */}
<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>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{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>
@@ -207,7 +207,7 @@ export default function ItemTypesPage() {
<TableBody>
{itemTypes.map((t) => (
<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">
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
@@ -215,7 +215,7 @@ export default function ItemTypesPage() {
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</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">
<div className="flex items-center gap-1">
<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 { Label } from "@/components/ui/label"
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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -721,122 +720,136 @@ export default function NewItemPage() {
</div>
</div>
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<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.
</p>
</div>
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<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.
</p>
</div>
<RadioGroup
value={priceMode}
onValueChange={(v) => setPriceMode(v as typeof priceMode)}
className="flex flex-col gap-3"
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
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">
<RadioGroupItem value="stock" />
<span className="text-base font-medium">Use stock value</span>
</label>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="fixed" />
<span className="text-base font-medium">Fixed price</span>
</label>
</RadioGroup>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
{priceMode === "fixed" && (
<div className="flex max-w-xl flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
</div>
)}
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<RadioGroup
value={warranty}
onValueChange={(v) => {
setWarranty(v as typeof warranty)
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
}}
className="flex flex-col gap-3"
>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="NonWarranty" />
<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>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
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>
@@ -875,7 +888,7 @@ export default function NewItemPage() {
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
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"
)}
>
@@ -1034,7 +1047,7 @@ export default function NewItemPage() {
<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
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)}
</TableCell>
{priceMode === "fixed" && (
@@ -1,80 +1,34 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import Link from "next/link"
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Download,
Eye,
ListTree,
Package,
Pencil,
Plus,
Ruler,
Search,
} from "lucide-react"
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
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 { 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 { 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"
type StatusFilter = EntityStatus | "All"
type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
type SortOrder = "asc" | "desc"
type TrackingFilter = TrackingMode | "All"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" },
{ key: "stockValue", label: "Stock Value" },
{ key: "lastPurchase", label: "Last Purchase" },
{ key: "sales30d", label: "Sales (30d)" },
{ key: "status", label: "Status" },
]
function formatMoney(n: number) {
return `LKR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
return (
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
{label}
{active ? (
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
const PAGE_SIZE = 10
export default function ItemsPage() {
const router = useRouter()
const isMobile = useIsMobile()
const [items, setItems] = useState<ItemOverview[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [brands, setBrands] = useState<Brand[]>([])
const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -84,158 +38,74 @@ export default function ItemsPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
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(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
setPage(1)
}
useEffect(() => setPage(1), [query, status, categoryId, trackingMode])
useEffect(() => {
let cancelled = false
// Mobile "Show more" appends the next server page to what's already listed.
const append = isMobile && page > 1
function load() {
setError(null)
itemsApi
.overview({
.list({
page,
pageSize,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
})
.then((res) => {
if (cancelled) return
setError(null)
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setItems(res.items)
setPagination(res.pagination)
if (!append) setSelected(new Set())
})
.catch((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)
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, query, status, categoryId, trackingMode])
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).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) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
}
function brandName(id: number | null) {
if (id === null) return "—"
function subCategoryName(id: number) {
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
}
function brandName(id: number) {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
const showCol = (key: string) => visibleColumns[key] ?? true
function toggleColumn(key: string, next: boolean) {
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
}
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
const sortedItems = useMemo(() => {
if (!items) return null
return [...items].sort((a, b) => {
let cmp: number
switch (sortKey) {
case "stockValue":
cmp = a.stockValue - b.stockValue
break
case "salesQtyLast30Days":
cmp = a.salesQtyLast30Days - b.salesQtyLast30Days
break
case "lastPurchaseDate":
cmp = (a.lastPurchaseDate ? new Date(a.lastPurchaseDate).getTime() : 0) -
(b.lastPurchaseDate ? new Date(b.lastPurchaseDate).getTime() : 0)
break
case "name":
cmp = a.name.localeCompare(b.name)
break
default:
cmp = a.sku.localeCompare(b.sku)
}
return sortOrder === "asc" ? cmp : -cmp
})
}, [items, sortKey, sortOrder])
const allSelected = !!sortedItems && sortedItems.length > 0 && selected.size === sortedItems.length
function toggleAll(checked: boolean) {
setSelected(checked && sortedItems ? new Set(sortedItems.map((i) => i.itemId)) : new Set())
}
function toggleRow(itemId: number, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(itemId)
else next.delete(itemId)
return next
})
}
function handleExport() {
if (!sortedItems) return
const rows = selected.size > 0 ? sortedItems.filter((i) => selected.has(i.itemId)) : sortedItems
downloadCsv(`items-${new Date().toISOString().slice(0, 10)}`, rows, [
{ header: "SKU", value: (i) => i.sku },
{ header: "Name", value: (i) => i.name },
{ header: "Category", value: (i) => categoryName(i.categoryId) },
{ header: "Brand", value: (i) => brandName(i.brandId) },
{ header: "Status", value: (i) => i.status },
{ header: "Stock Qty", value: (i) => i.stockQty },
{ header: "Stock Value (LKR)", value: (i) => i.stockValue.toFixed(2) },
{ header: "Low Stock", value: (i) => (i.isLowStock ? "Yes" : "No") },
{ header: "Last Purchase Date", value: (i) => i.lastPurchaseDate ?? "" },
{ header: "Last Supplier", value: (i) => i.lastSupplierName ?? "" },
{ header: "Sales Qty (30d)", value: (i) => i.salesQtyLast30Days },
])
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "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">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 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" }))}>
<Ruler className="size-5" />
UOMs
@@ -279,8 +149,19 @@ export default function ItemsPage() {
))}
</SelectContent>
</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")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -289,18 +170,13 @@ export default function ItemsPage() {
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</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>
{error && (
<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">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -308,7 +184,7 @@ export default function ItemsPage() {
</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">
<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>
@@ -321,239 +197,92 @@ export default function ItemsPage() {
</div>
)}
{!error && sortedItems !== null && sortedItems.length > 0 && (
{!error && items !== null && items.length > 0 && (
<>
<div className="flex flex-col gap-3 md:hidden">
{sortedItems.map((item) => (
<div
key={item.itemId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/products/${item.itemId}`)
}}
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="flex min-w-0 items-center gap-3">
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
data-no-row-nav
className="mt-0.5 shrink-0"
/>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{item.name}</p>
<p className="text-sm text-muted-foreground">{item.sku}</p>
</div>
</div>
{showCol("status") && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.itemId}>
<TableCell className="px-3 py-3.5 font-medium">
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
{item.sku}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
</TableCell>
)}
{config?.brandsEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.brandId !== null ? brandName(item.brandId) : "—"}
</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
variant="outline"
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}
</Badge>
)}
</div>
{showCol("categoryBrand") && (
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
)}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{showCol("stockValue") && (
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</div>
)}
{showCol("sales30d") && (
<div>
<p className="text-muted-foreground">Sales (30d)</p>
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
</div>
)}
{showCol("lastPurchase") && (
<div>
<p className="text-muted-foreground">Last Purchase</p>
{item.lastPurchaseDate ? (
<>
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
</>
) : (
<p className="text-foreground">—</p>
)}
</div>
)}
</div>
<Link
href={`/dashboard/products/${item.itemId}`}
data-no-row-nav
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-3.5" />
Edit
</Link>
</div>
))}
{pagination && pagination.page < pagination.totalPages && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={showMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3">
<Checkbox checked={allSelected} onCheckedChange={(c) => toggleAll(c === true)} aria-label="Select all" />
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item) => (
<TableRow
key={item.itemId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
{item.sku}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</TableCell>
)}
{showCol("lastPurchase") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${item.sku}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
@@ -92,7 +92,7 @@ export default function ProductSettingsPage() {
{!error && !config && <Skeleton className="h-80 w-full" />}
{!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">
<Package className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
@@ -1,7 +1,7 @@
"use client"
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 { 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -346,7 +345,39 @@ export default function TaxRatesPage() {
</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>
@@ -1,16 +1,129 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Plus, Ruler } from "lucide-react"
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
// to keep old links/bookmarks working.
export default function UomsRedirectPage() {
const router = useRouter()
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data"
useEffect(() => {
router.replace("/dashboard/products/brands?tab=uom")
}, [router])
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 { useParams } from "next/navigation"
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 { warehousesApi } from "@/lib/api/warehouses"
@@ -154,11 +154,11 @@ export default function GrnDetailPage() {
</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" && (
<Link
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"
>
<BookOpen className="size-5" />
@@ -166,7 +166,7 @@ export default function GrnDetailPage() {
</Link>
)}
{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" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
@@ -193,110 +193,7 @@ export default function GrnDetailPage() {
</div>
)}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{grn.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
</div>
<HoldStatusBadge status={line.holdStatus} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM / Bin</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)} · {binFor(line.binId)}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground">
{line.unitCost.toFixed(2)}
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
<span className="block text-xs text-warning">
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
</span>
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Disc % / Net cost</p>
<p className="font-medium text-foreground">{line.discountPct.toFixed(2)}% / {line.netUnitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Received value</p>
<p className="font-medium text-foreground">{line.receivedValue.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">VAT</p>
<p className="font-medium text-foreground">{line.vatAmount.toFixed(2)} ({line.vatPct.toFixed(2)}%)</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Line total</p>
<p className="font-semibold text-foreground">{line.lineTotal.toFixed(2)}</p>
</div>
</div>
{grn.status === "Confirmed" && (
<div className="border-t pt-3">
{line.holdStatus === "OnHold" ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="success"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Release")}
>
<ShieldAlert className="size-4" />
Release
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Reject")}
>
<XCircle className="size-4" />
Reject
</Button>
</div>
) : line.holdStatus === "Rejected" ? (
<Link
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<PackageX className="size-4" />
Create Return
</Link>
) : (
<span className="text-sm text-muted-foreground">No actions</span>
)}
</div>
)}
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -442,9 +339,9 @@ export default function GrnDetailPage() {
<span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span>
<Link
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
</Link>
</div>
@@ -834,190 +834,7 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<>
{/* 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">
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
@@ -1226,8 +1043,7 @@ export default function NewGrnPage() {
})}
</TableBody>
</Table>
</div>
</>
</div>
)}
{!poLoading && lineTab === "warranty" && (
@@ -1307,7 +1123,7 @@ export default function NewGrnPage() {
<TableBody>
{Array.from({ length: units }, (_, 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}
</TableCell>
<TableCell className="px-3 py-3 align-top">
@@ -1363,11 +1179,11 @@ export default function NewGrnPage() {
)}
{lineTab === "lines" && (
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<div className="flex justify-end gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
@@ -2,15 +2,11 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
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 { GrnStatus, GrnSummary } from "@/types/grn"
import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
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 { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
@@ -34,19 +29,13 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const PAGE_SIZE = 5
export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
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 [error, setError] = useState<string | null>(null)
@@ -54,22 +43,6 @@ export default function GrnListPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
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.
@@ -88,7 +61,7 @@ export default function GrnListPage() {
grnsApi
.list({
page,
pageSize,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
@@ -98,15 +71,9 @@ export default function GrnListPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
useEffect(load, [page, query, status, activeBranchId, branchingEnabled])
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.
</p>
</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" />
New GRN
</Link>
@@ -172,21 +139,9 @@ export default function GrnListPage() {
)}
{!error && grns === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
@@ -220,165 +175,101 @@ export default function GrnListPage() {
{!error && grns !== null && grns.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{grns.map((grn) => (
<div
key={grn.grnId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
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">{grn.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
</div>
<GrnStatusBadge status={grn.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">PO</p>
<p className="font-medium text-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>
) : (
"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>
<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">PO</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">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => {
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-medium text-foreground hover:underline"
>
{grn.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell>
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell>
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell>
<TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell className="px-3 py-3.5">
{grn.status !== "Confirmed" ? (
"—"
<span className="text-sm text-muted-foreground">—</span>
) : 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}`}
</Link>
) : (
"Not invoiced"
<span className="text-sm text-muted-foreground">Not invoiced</span>
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
>
<Eye className="size-4" />
</Link>
{pagination && grns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-40">Doc No</TableHead>
<TableHead>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>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => {
return (
<TableRow
key={grn.grnId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
>
<TableCell>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-semibold text-foreground hover:underline"
>
{grn.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
<span>Direct</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
<TableCell>
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell data-no-row-nav className="text-sm text-muted-foreground">
{grn.status !== "Confirmed" ? (
<span>—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<span>Not invoiced</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)
})}
</TableBody>
</Table>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
@@ -2,22 +2,12 @@
import { useEffect, useMemo, useState } from "react"
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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { cn } from "@/lib/utils"
@@ -28,15 +18,9 @@ import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
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 }
const blankLine = (key: string, warehouseId: number): Line => ({
@@ -58,26 +42,13 @@ export default function BundleTemplatesPage() {
const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([])
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 [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
async function refreshTemplates() {
const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
const res = await bundleApi.listTemplates({ pageSize: 50 })
setTemplates(res.items)
setPagination(res.pagination)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
@@ -99,17 +70,6 @@ export default function BundleTemplatesPage() {
.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(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items]
@@ -124,20 +84,18 @@ export default function BundleTemplatesPage() {
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() {
setTemplateName("")
setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)])
setError(null)
}
function openCreateForm() {
resetForm()
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) resetForm()
}
const componentSubtotal = useMemo(
@@ -166,7 +124,6 @@ export default function BundleTemplatesPage() {
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm()
setFormOpen(false)
await refreshTemplates()
} catch (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&apos;s ready to sell from Create Sale.</p>
</div>
</div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" />
New Bundle Template
</Button>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>New bundle template</DialogTitle>
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
</DialogHeader>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"}
</Button>
</div>
{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>
<Field>
<FieldLabel htmlFor="bt-name">Template name</FieldLabel>
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</Field>
<Field>
<FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<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" />
</Field>
</FieldGroup>
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">Bundle details</h2>
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Template name</Label>
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<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">Component items</h2>
<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
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => 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={`bt-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`bt-qty-${line.key}`}
type="number"
min="0.0001"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
<Input
id={`bt-price-${line.key}`}
type="number"
min="0"
step="0.01"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel>Include in bundle</FieldLabel>
</TableCell>
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<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" />
</TableCell>
<TableCell className="px-4 py-2">
<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" />
</TableCell>
<TableCell className="px-4 py-2 text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
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 ? "Included" : "Excluded"}
</button>
</Field>
</FieldGroup>
))}
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div>
<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…" : "Save bundle template"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
</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>
<div className="flex justify-end border-t border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div>
</section>
<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>
<h3 className="text-sm font-semibold">Bundle templates</h3>
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshTemplates}>
<Button variant="outline" size="sm" onClick={refreshTemplates}>
Refresh
</Button>
</div>
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search template name or code…"
className="mt-3 h-10 w-full max-w-sm text-sm"
aria-label="Search bundle templates"
/>
{templates.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<div key={t.bundleSaleTemplateId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.templateName}</p>
<p className="text-sm text-muted-foreground">{t.templateCode}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
{t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{t.lineCount}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
</div>
</div>
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</div>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Template</TableHead>
<TableHead className="h-12 px-4 text-sm">Description</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
{templates.length > 0 ? (
templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5">
<div className="font-medium">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">
No bundle templates yet — save one above.
</TableCell>
</TableRow>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Template</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-32 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.updatedAt ? new Date(t.updatedAt).toLocaleString() : "—"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No bundle templates yet — create one above.
</div>
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
@@ -236,45 +236,32 @@ export default function SalesDayEndPage() {
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
{report.itemBreakdown.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
) : (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{report.itemBreakdown.map((line) => (
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{line.qty} × {line.revenue.toFixed(2)}
</span>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.itemBreakdown.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell>
</TableRow>
) : (
report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
))
)}
</TableBody>
</Table>
</div>
</div>
{report.slipNumbers.length > 0 && (
@@ -2,22 +2,11 @@
import { useEffect, useMemo, useState } from "react"
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 { Input } from "@/components/ui/input"
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 { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
@@ -30,16 +19,10 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
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 FreeIssueRow = {
@@ -82,25 +65,14 @@ export default function NewFreeIssuePage() {
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
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 [editingLines, setEditingLines] = useState<Line[]>([])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
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() {
const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const list = await salesApi.listFreeIssues({ pageSize: 50 })
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
@@ -154,10 +126,6 @@ export default function NewFreeIssuePage() {
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
}, [page, pageSize])
function updateLine(key: string, patch: Partial<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)))
}
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(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
[items]
@@ -222,7 +202,6 @@ export default function NewFreeIssuePage() {
}
setLines([blankLine("line-1")])
setFormOpen(false)
await refreshRows()
} catch (err) {
setError(errorMessage(err))
@@ -257,7 +236,6 @@ export default function NewFreeIssuePage() {
parentLineId: detailLine?.parentLineId ?? null,
},
])
setFormOpen(true)
} catch (err) {
setError(errorMessage(err))
}
@@ -268,25 +246,12 @@ export default function NewFreeIssuePage() {
setEditingLines([])
}
function openCreateForm() {
cancelEdit()
setError(null)
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) cancelEdit()
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) {
cancelEdit()
setFormOpen(false)
}
if (editingRowId === row.salesSlipId) cancelEdit()
await refreshRows()
} catch (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>
</div>
</div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
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>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{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)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-between">
<div>
<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>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
<Button variant="outline" size="sm" onClick={refreshRows}>
Refresh
</Button>
</div>
{rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="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>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead>
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-4 text-sm">Product</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 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length > 0 ? (
rows.map((row) => (
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5">
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell>
<TableCell className="px-4 py-3.5 min-w-80">
<div className="font-medium">{row.itemName}</div>
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => startEdit(row)}
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800"
>
<Pencil className="mr-1 size-3.5" />
Edit
</button>
{row.status === "Draft" ? (
<button
type="button"
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"
>
<Trash2 className="mr-1 size-3.5" />
Delete
</button>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">
No free issues created yet.
</TableCell>
</TableRow>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
@@ -202,7 +202,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
async function save() {
if (!customerId || !warehouseId || !etag || !invoice) return
if (!customerId || !warehouseId || !etag) return
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
return
@@ -216,7 +216,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{
customerId,
warehouseId,
branchId: invoice.branchId,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
@@ -449,14 +449,12 @@ function NewSalesInvoiceContent() {
async function submit() {
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.warehouseId)) return setSubmitError("Select a warehouse for every line.")
const payload: CreateSalesInvoiceRequest = {
customerId,
warehouseId,
branchId: effectiveBranchId ?? 0,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
@@ -603,182 +601,7 @@ function NewSalesInvoiceContent() {
</div>
</div>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{displayRows.map((row, idx) => {
if (row.type === "bundle") {
const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<div key={row.groupId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-foreground">Bundle: {row.bundleName}</p>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
</li>
))}
</ul>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Bundles</Label>
<Input
type="number"
min="1"
step="1"
value={bundleQty}
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
aria-label="Bundle quantity"
/>
</div>
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Total</span>
<span className="font-semibold tabular-nums">{lkr.format(bundleTotal)}</span>
</div>
</div>
)
}
const line = row.line
const { net: lineNet } = lineTotals(line)
const scheme = freeIssueSchemeFor(line.itemId)
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item {idx + 1}</Label>
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div>
<Label className="text-sm">Unit price</Label>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</p>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Line total</span>
<span className="font-semibold tabular-nums">{lkr.format(lineNet)}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
@@ -801,7 +624,7 @@ function NewSalesInvoiceContent() {
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<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">
<div className="font-medium">Bundle: {row.bundleName}</div>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
@@ -812,7 +635,7 @@ function NewSalesInvoiceContent() {
))}
</ul>
</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">
<Input
type="number"
@@ -825,11 +648,11 @@ function NewSalesInvoiceContent() {
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</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 || "—"}
</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">—</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 text-muted-foreground">—</TableCell>
<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>
</TableCell>
@@ -855,7 +678,7 @@ function NewSalesInvoiceContent() {
return (
<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">
<Combobox
items={lineComboOptions}
@@ -948,31 +771,31 @@ function NewSalesInvoiceContent() {
</Table>
</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 items-baseline justify-between gap-2 sm:justify-start">
<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 gap-2">
<span className="text-sm text-muted-foreground">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
</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="tabular-nums">-{lkr.format(discountTotal)}</span>
</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="tabular-nums">{lkr.format(taxTotal)}</span>
</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="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
</div>
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</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"}
</Button>
</div>
@@ -2,8 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, FileText, Plus, Printer } from "lucide-react"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -13,7 +12,6 @@ import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Field, FieldLabel } from "@/components/ui/field"
import { KpiRow } from "@/components/ui/kpi-row"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -30,10 +28,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
const PAGE_SIZE = 10
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -42,7 +37,6 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
]
export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
@@ -53,8 +47,6 @@ export default function SalesInvoicesPage() {
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
@@ -69,10 +61,7 @@ export default function SalesInvoicesPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
setPageSize(PAGE_SIZE)
}, [query, status, activeBranchId, customerId, warehouseId])
useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
@@ -84,7 +73,7 @@ export default function SalesInvoicesPage() {
salesApi
.listInvoices({
page,
pageSize,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
@@ -95,13 +84,7 @@ export default function SalesInvoicesPage() {
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
}, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId])
async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId)
@@ -151,17 +134,17 @@ export default function SalesInvoicesPage() {
<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>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<div className="flex flex-wrap gap-2">
<Link
href={printHref}
target="_blank"
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" />
Print batch
</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" />
New Sale
</Link>
@@ -238,112 +221,7 @@ export default function SalesInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Gross</div>
<div className="font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Discount</div>
<div className="font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Net</div>
<div className="font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
{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>
{(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.salesInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "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>
))}
{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">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -361,25 +239,18 @@ export default function SalesInvoicesPage() {
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow
key={row.salesInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
>
<TableRow key={row.salesInvoiceId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo}
</Link>
</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">{new Date(row.invoiceDate).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 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.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">
<div className="flex flex-col items-start gap-1">
@@ -389,7 +260,7 @@ export default function SalesInvoicesPage() {
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<TableCell className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
@@ -416,6 +287,14 @@ export default function SalesInvoicesPage() {
Pay
</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>
</TableCell>
</TableRow>
@@ -424,7 +303,7 @@ export default function SalesInvoicesPage() {
</Table>
</div>
<div className="hidden border-t px-4 py-3 md:block">
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Gross total</div>
@@ -441,9 +320,22 @@ export default function SalesInvoicesPage() {
</div>
</div>
{pagination && (
<div className="hidden border-t px-4 py-4 md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
@@ -1,10 +1,34 @@
"use client"
import { Suspense } from "react"
import { Suspense, useEffect, useState } from "react"
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 { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
}
function NewSalesReturnContent() {
const router = useRouter()
@@ -12,6 +36,143 @@ function NewSalesReturnContent() {
const presetInvoiceId = Number(searchParams.get("invoiceId")) || 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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -21,12 +182,118 @@ function NewSalesReturnContent() {
</div>
</div>
<SalesReturnForm
presetInvoiceId={presetInvoiceId}
presetLineId={presetLineId}
onCancel={() => router.push("/dashboard/sales/sales-returns")}
onSuccess={() => router.push("/dashboard/sales/sales-returns")}
/>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{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>
)
}
@@ -9,71 +9,32 @@ import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { SalesReturnSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalesReturnsListPage() {
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
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]) => {
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([r, c, w, rc]) => {
setReturns(r.items)
setCustomers(c.items)
setWarehouses(w.items)
setReasonCodes(rc.items)
})
.catch(() => {})
.catch((err) => setError(errorMessage(err)))
}, [])
function customerLabel(id: number) {
@@ -87,8 +48,6 @@ export default function SalesReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
}
const hasFilters = query.length > 0
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -96,42 +55,20 @@ export default function SalesReturnsListPage() {
<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>
</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" />
New Return
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., customer…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search sales returns"
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && returns === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
@@ -139,113 +76,47 @@ export default function SalesReturnsListPage() {
{!error && returns !== null && returns.length === 0 && (
<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" />
<p className="text-base text-muted-foreground">{hasFilters ? "No sales returns match your search." : "No sales returns yet."}</p>
{!hasFilters && (
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
)}
<p className="text-base text-muted-foreground">No sales returns yet.</p>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
)}
{!error && returns !== null && returns.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">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) => (
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</p>
</div>
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
<TableRow key={r.returnId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
</div>
<div>
<p className="text-muted-foreground">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>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
{pagination && returns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-40">Doc No</TableHead>
<TableHead className="w-44">Customer</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-36">Reason</TableHead>
<TableHead className="w-28 text-right">Return Qty</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-32">GL Journal</TableHead>
<TableHead className="w-40">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
</TableBody>
</Table>
)}
</div>
)
@@ -86,7 +86,7 @@ export default function BranchConfigurationPage() {
{!error && !config && <Skeleton className="h-40 w-full" />}
{!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">
<MapPin className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Branches</h2>
@@ -160,7 +160,7 @@ export default function CompanyProfilePage() {
{!error && !profile && <Skeleton className="h-96 w-full" />}
{!error && profile && (
<div className="flex flex-col gap-6 rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Identity & contact</h2>
@@ -272,14 +272,14 @@ export default function CompanyProfilePage() {
)}
</div>
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-between border-t pt-4">
{profile.updatedAt && (
<p className="text-sm text-muted-foreground">
Last changed {new Date(profile.updatedAt).toLocaleString()}
{profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.
</p>
)}
<Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
@@ -106,7 +106,7 @@ export default function InvoiceTemplatePage() {
{!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">
<LayoutTemplate className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Layout</h2>
@@ -172,20 +172,20 @@ export default function InvoiceTemplatePage() {
</Field>
</FieldGroup>
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-between pt-2">
{config.updatedAt && (
<p className="text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
<Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-4 sm:p-6">
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span>
{(() => {
const layout = getInvoiceLayoutClasses(templateStyle)
@@ -199,7 +199,7 @@ export default function InvoiceTemplatePage() {
const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} />
return (
<div className={`flex flex-col rounded-2xl border bg-white ${layout.sheet}`}>
<div className={`rounded-2xl border bg-white ${layout.sheet}`}>
{layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock}
@@ -225,7 +225,7 @@ export default function InvoiceTemplatePage() {
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div>
</div>
) : (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${layout.infoGrid}`}>
<div className={`grid grid-cols-2 ${layout.infoGrid}`}>
<div className={layout.card}>
<div className={layout.cardLabel}>Customer</div>
<div className={layout.cardValue}>Sample Customer Ltd</div>
@@ -243,24 +243,22 @@ export default function InvoiceTemplatePage() {
)}
<div className={layout.tableWrap}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className={layout.tableHeaderRow}>
<tr>
<th className="p-2 text-left font-semibold">Item</th>
<th className="p-2 text-right font-semibold">Qty</th>
<th className="p-2 text-right font-semibold">Line total</th>
</tr>
</thead>
<tbody>
<tr className="border-t">
<td className="p-2">Sample product</td>
<td className="p-2 text-right">2.00</td>
<td className="p-2 text-right">12,450.00</td>
</tr>
</tbody>
</table>
</div>
<table className="w-full text-sm">
<thead className={layout.tableHeaderRow}>
<tr>
<th className="p-2 text-left font-semibold">Item</th>
<th className="p-2 text-right font-semibold">Qty</th>
<th className="p-2 text-right font-semibold">Line total</th>
</tr>
</thead>
<tbody>
<tr className="border-t">
<td className="p-2">Sample product</td>
<td className="p-2 text-right">2.00</td>
<td className="p-2 text-right">12,450.00</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4">
@@ -164,26 +164,28 @@ export default function RoleDetailPage() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{role.status}
</Badge>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{role.status}
</Badge>
</div>
<p className="text-base text-muted-foreground">{role.name}</p>
</div>
<p className="text-base text-muted-foreground">{role.name}</p>
</div>
<Button
variant="outline"
className={cn("w-full sm:w-auto", role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
className={role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10"}
onClick={handleToggleStatus}
disabled={togglingStatus || role.isSystemRole}
title={role.isSystemRole ? "System roles can't be deactivated" : undefined}
@@ -278,12 +280,12 @@ export default function RoleDetailPage() {
{!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p>
)}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" />
Back
</Button>
<Button className="w-full sm:w-auto" onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}>
<Button onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}>
<Save className="size-4" />
{savingPermissions ? "Saving…" : "Save permissions"}
</Button>
@@ -179,16 +179,16 @@ export default function NewRolePage() {
{!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex flex-col-reverse gap-3 sm:flex-row">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<div className="flex gap-3">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
<Button className="w-full sm:w-auto" onClick={handleCreate} disabled={submitting || !allActionsSet}>
<Button onClick={handleCreate} disabled={submitting || !allActionsSet}>
<Save className="size-4" />
{submitting ? "Creating…" : "Create role"}
</Button>
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
@@ -15,54 +14,32 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function RolesPage() {
const router = useRouter()
const [roles, setRoles] = useState<Role[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() {
rolesApi
.list({ page, pageSize, q: debouncedSearch || undefined })
.list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined })
.then((res) => {
setRoles(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(load, [page, debouncedSearch])
useEffect(() => {
const t = setTimeout(() => {
@@ -181,212 +158,150 @@ export default function RolesPage() {
)}
{!error && roles !== null && roles.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{roles.map((r) => (
<div
key={r.roleId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/roles/${r.roleId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/roles/${r.roleId}`) }}
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="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="truncate font-semibold leading-tight text-foreground">{r.name}</span>
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</div>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{r.status}
</Badge>
</div>
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
<div className="flex items-center gap-1" data-no-row-nav>
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-56 px-3 text-sm">Role</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</div>
</div>
))}
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
</Link>
</TableCell>
{pagination && roles.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</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 w-56 px-3 text-sm">Role</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow
key={r.roleId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/roles/${r.roleId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${r.code}`}
title={`View ${r.code}`}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
<Eye className="size-4" />
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
>
{r.status}
</Badge>
</TableCell>
<Pencil className="size-4" />
</Link>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
</div>
)}
{pagination && pagination.totalPages > 1 && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
/>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>

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