Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
101 changed files with 1631 additions and 1048 deletions
+2 -14
View File
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -16,9 +15,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)]
@@ -28,14 +26,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();
}
}
+5
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from src/dest warehouse.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
-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; }
}
+5 -2
View File
@@ -15,13 +15,13 @@ public sealed record GrnLineDto(
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <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, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
@@ -81,6 +81,9 @@ public sealed class CreateGrnRequest
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -15,14 +15,14 @@ public sealed record SalesInvoiceTotalsDto(
public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, int BranchId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto(
@@ -65,6 +65,9 @@ public sealed class CreateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
@@ -73,6 +76,9 @@ public sealed class UpdateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
+5 -2
View File
@@ -8,13 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
@@ -31,6 +31,9 @@ public sealed class CreateAdjustmentLineInput
public sealed class CreateAdjustmentRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
+5 -2
View File
@@ -8,12 +8,12 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
@@ -23,6 +23,9 @@ public sealed record CountPostResultDto(int CountId, CountStatus Status, int? Ad
public sealed class CreateCountRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
}
+5 -2
View File
@@ -9,12 +9,12 @@ public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -42,6 +42,9 @@ public sealed class CreateTransferRequest
{
[Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; }
/// <summary>Owning branch for this transfer — warehouses are shared, so this is what
/// actually scopes the document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
+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; }
}
@@ -22,11 +22,13 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Branch).WithMany().HasForeignKey(g => g.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
builder.HasIndex(g => g.BranchId);
}
}
@@ -30,6 +30,11 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch)
.WithMany()
.HasForeignKey(x => x.BranchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.InvoiceType)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C);
@@ -47,6 +52,7 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate);
builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice)
@@ -21,7 +21,10 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Branch).WithMany().HasForeignKey(a => a.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => a.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Branch).WithMany().HasForeignKey(t => t.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BranchId);
}
}
@@ -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);
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260914061430_AddUomLifecycleColumnsLocal")]
partial class AddUomLifecycleColumnsLocal
[Migration("20260908101847_AddBranchIdToDocuments")]
partial class AddBranchIdToDocuments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -1450,6 +1450,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1496,6 +1499,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4190,6 +4195,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4282,6 +4290,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId");
@@ -4862,6 +4872,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4899,6 +4912,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4961,6 +4976,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType")
.IsRequired()
.HasMaxLength(20)
@@ -4993,6 +5011,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -5185,6 +5205,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -5215,6 +5238,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId");
@@ -5762,31 +5787,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);
});
@@ -6398,6 +6408,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -6426,6 +6442,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
@@ -7160,6 +7178,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatorUserId");
@@ -7176,6 +7200,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Customer");
@@ -7441,6 +7467,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7459,6 +7491,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("ReasonCode");
@@ -7502,6 +7536,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7514,6 +7554,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Warehouse");
@@ -7621,6 +7663,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7639,6 +7687,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("DestWarehouse");
@@ -0,0 +1,199 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchIdToDocuments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_transfers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_counts",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_adjustments",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "sales_invoices",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "grns",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_grns_BranchId",
table: "grns",
column: "BranchId");
// Backfill from each document's existing warehouse — warehouses were the sole
// branch anchor until now, so this preserves current branch assignment exactly.
// (Warehouse.BranchId is NOT NULL, so every row is resolvable; the WHERE BranchId = 0
// guard just keeps this idempotent if the migration is ever re-run against partial state.)
migrationBuilder.Sql(@"
UPDATE grns g SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = g.""WarehouseId"" AND g.""BranchId"" = 0;
UPDATE sales_invoices x SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = x.""WarehouseId"" AND x.""BranchId"" = 0;
UPDATE stock_counts c SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = c.""WarehouseId"" AND c.""BranchId"" = 0;
UPDATE stock_adjustments a SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = a.""WarehouseId"" AND a.""BranchId"" = 0;
UPDATE stock_transfers t SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = t.""SrcWarehouseId"" AND t.""BranchId"" = 0;
");
migrationBuilder.AddForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns");
migrationBuilder.DropForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices");
migrationBuilder.DropForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments");
migrationBuilder.DropForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts");
migrationBuilder.DropForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts");
migrationBuilder.DropIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments");
migrationBuilder.DropIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_BranchId",
table: "grns");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_transfers");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_counts");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "BranchId",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "BranchId",
table: "grns");
}
}
}
@@ -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");
}
}
}
@@ -1447,6 +1447,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1493,6 +1496,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4187,6 +4192,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4279,6 +4287,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId");
@@ -4859,6 +4869,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4896,6 +4909,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4958,6 +4973,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType")
.IsRequired()
.HasMaxLength(20)
@@ -4990,6 +5008,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -5182,6 +5202,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -5212,6 +5235,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId");
@@ -5759,31 +5784,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);
});
@@ -6395,6 +6405,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -6423,6 +6439,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
@@ -7157,6 +7175,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatorUserId");
@@ -7173,6 +7197,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Customer");
@@ -7438,6 +7464,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7456,6 +7488,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("ReasonCode");
@@ -7499,6 +7533,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7511,6 +7551,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Warehouse");
@@ -7618,6 +7660,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7636,6 +7684,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("DestWarehouse");
+10 -4
View File
@@ -32,6 +32,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -41,7 +42,7 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_adjustments = adjustments;
@@ -52,6 +53,7 @@ public sealed class AdjustmentService : IAdjustmentService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -105,14 +107,15 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
.ToListAsync(ct);
@@ -145,6 +148,8 @@ public sealed class AdjustmentService : IAdjustmentService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment)
@@ -169,6 +174,7 @@ public sealed class AdjustmentService : IAdjustmentService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -196,7 +202,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
+11 -4
View File
@@ -34,6 +34,7 @@ public sealed class CountService : ICountService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -43,7 +44,7 @@ public sealed class CountService : ICountService
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_counts = counts;
@@ -55,6 +56,7 @@ public sealed class CountService : ICountService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -108,13 +110,14 @@ public sealed class CountService : ICountService
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the count carries its own owning BranchId.
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
@@ -133,6 +136,8 @@ public sealed class CountService : ICountService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
@@ -153,6 +158,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
CountType = request.CountType,
Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -222,6 +228,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -250,7 +257,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+2 -2
View File
@@ -62,11 +62,11 @@ public sealed class DashboardService : IDashboardService
.ToList();
var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
+11 -7
View File
@@ -38,6 +38,7 @@ public sealed class GrnService : IGrnService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -49,8 +50,8 @@ public sealed class GrnService : IGrnService
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_grns = grns;
_pos = pos;
@@ -65,6 +66,7 @@ public sealed class GrnService : IGrnService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
@@ -86,15 +88,14 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -116,6 +117,8 @@ public sealed class GrnService : IGrnService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
int vendorId;
@@ -211,6 +214,7 @@ public sealed class GrnService : IGrnService
PoId = request.PoId,
VendorId = vendorId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
@@ -448,7 +452,7 @@ public sealed class GrnService : IGrnService
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
@@ -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);
}
@@ -26,6 +26,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
@@ -33,7 +34,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
ICurrentUser currentUser, IBranchConfigService branchConfig, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
_customers = customers;
@@ -45,6 +46,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_branchConfig = branchConfig;
_numbers = numbers;
_uow = uow;
}
@@ -60,9 +62,8 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -83,6 +84,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -96,6 +98,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -119,6 +122,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
invoice.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -201,5 +205,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
}
@@ -31,7 +31,7 @@ public sealed class SalesMappingService : ISalesMappingService
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new(
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, invoice.BranchId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
invoice.GlJournalNo, invoice.GlPostedAt,
MapInvoiceTotals(invoice),
+10 -6
View File
@@ -34,11 +34,13 @@ public sealed class TransferService : ITransferService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
@@ -46,6 +48,7 @@ public sealed class TransferService : ITransferService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -62,15 +65,14 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side
// rather than only the branch that happens to hold the source warehouse.
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
// Warehouses are shared across branches — the transfer carries its own owning BranchId.
if (branchId is not null) q = q.Where(t => t.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
@@ -90,6 +92,7 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
@@ -103,6 +106,7 @@ public sealed class TransferService : ITransferService
DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId,
BranchId = branchId,
Status = TransferStatus.Draft,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
@@ -227,7 +231,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}
+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);
}
}
@@ -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}
@@ -15,12 +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
const PAGE_SIZE = 10
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
@@ -138,7 +137,16 @@ export default function AttendanceBatchesPage() {
</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>
)
}
@@ -17,13 +17,12 @@ 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"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
const emptyForm: CreateEmployeeRequest = {
employeeCode: "",
@@ -309,7 +308,16 @@ export default function EmployeesPage() {
</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>
)
}
@@ -16,13 +16,12 @@ 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 { 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 = 25
const PAGE_SIZE = 10
const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
Draft: "bg-muted text-muted-foreground",
@@ -184,7 +183,16 @@ export default function LeaveRequestsPage() {
</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>
)
}
@@ -15,12 +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
const PAGE_SIZE = 10
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
Draft: "bg-muted text-muted-foreground",
@@ -125,7 +124,16 @@ export default function PayrollRunsPage() {
</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>
)
}
@@ -14,13 +14,12 @@ 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"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
const NONE = "__none__"
export default function DepartmentsPage() {
@@ -184,7 +183,16 @@ export default function DepartmentsPage() {
</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>
)
}
@@ -15,13 +15,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 = 25
const PAGE_SIZE = 10
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
export default function DocumentTypesPage() {
@@ -162,7 +161,16 @@ export default function DocumentTypesPage() {
</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>
)
}
@@ -15,12 +15,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 = 25
const PAGE_SIZE = 10
export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -165,7 +164,16 @@ export default function LeaveTypesPage() {
</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>
)
}
@@ -15,13 +15,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 = 25
const PAGE_SIZE = 10
export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -162,7 +161,16 @@ export default function SalaryComponentsPage() {
</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>
)
}
@@ -15,12 +15,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 = 25
const PAGE_SIZE = 10
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
export default function WorkShiftsPage() {
@@ -208,7 +207,16 @@ export default function WorkShiftsPage() {
</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>
)
}
@@ -128,7 +128,7 @@ export default function GeneralLedgerReportPage() {
<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-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>
+13 -11
View File
@@ -6,6 +6,7 @@ import {
AlertTriangle,
BadgeDollarSign,
Boxes,
Clock,
ClipboardList,
ListChecks,
PackageCheck,
@@ -127,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">
@@ -136,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>
@@ -150,11 +154,10 @@ 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">
@@ -177,7 +180,7 @@ export default function DashboardPage() {
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
backgroundColor: "#6366f1",
},
]}
/>
@@ -212,7 +215,6 @@ export default function DashboardPage() {
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -241,9 +243,9 @@ export default function DashboardPage() {
<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 className="font-medium text-foreground">#{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
</TableCell>
<TableCell>
<Badge
@@ -258,11 +260,11 @@ export default function DashboardPage() {
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
@@ -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>
@@ -2,13 +2,12 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map"
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"
@@ -20,7 +19,7 @@ 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" },
@@ -199,10 +198,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>
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
@@ -272,9 +271,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>
)}
</>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -14,7 +14,6 @@ import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -23,7 +22,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
export default function PurchaseOrdersListPage() {
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -261,7 +260,31 @@ export default function PurchaseOrdersListPage() {
</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>
@@ -107,7 +107,7 @@ export default function PurchaseReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</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>
))}
@@ -9,8 +9,7 @@ import { errorMessage } from "@/lib/error-map"
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Button, buttonVariants } from "@/components/ui/button"
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"
@@ -18,7 +17,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 [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
@@ -115,14 +114,36 @@ export default function RequisitionsListPage() {
<RequisitionStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</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))}>
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>
@@ -213,7 +213,7 @@ export default function ProducedItemsPage() {
<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">
<TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
@@ -19,7 +19,6 @@ 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"
@@ -426,7 +425,22 @@ export default function ProductionRunsPage() {
})}
</div>
{pagination && <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>
)}
</>
)}
</div>
@@ -22,7 +22,6 @@ 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"
@@ -287,7 +286,26 @@ export default function ProductionTemplatesPage() {
)}
</div>
{pagination && <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>
)}
</>
)}
</div>
@@ -1,193 +1,223 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-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() {
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
<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>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</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>
{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>
)}
@@ -195,171 +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 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
<>
<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"
)}
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
{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>
{/* 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}
/>
<span className="font-medium">{b.name}</span>
</label>
}
>
{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>
)}
<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>
</>
)}
</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>
)}
{!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 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
/>
<span className="font-medium">{u.name}</span>
</label>
))}
</div>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</div>
</button>
)
}
@@ -179,7 +179,7 @@ export default function CategorySubCategoriesPage() {
<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 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
@@ -192,7 +192,7 @@ export default function CategorySubCategoriesPage() {
{s.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</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)}>
@@ -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)
@@ -279,14 +247,13 @@ export default function CategoriesPage() {
<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 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
@@ -299,15 +266,10 @@ export default function CategoriesPage() {
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
)}
<TableCell className="px-3 py-3.5 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.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
{/* 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" }))}
@@ -315,7 +277,6 @@ export default function CategoriesPage() {
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
@@ -360,7 +321,39 @@ export default function CategoriesPage() {
</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,10 +720,9 @@ 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 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">
@@ -732,23 +730,32 @@ export default function NewItemPage() {
</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 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
@@ -798,27 +805,35 @@ export default function NewItemPage() {
</p>
</div>
<RadioGroup
value={warranty}
onValueChange={(v) => {
setWarranty(v as typeof warranty)
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className="flex flex-col gap-3"
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",
)}
>
<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>
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="mt-2 flex flex-col gap-1.5">
<Label>Warranty period +</Label>
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
@@ -839,8 +854,6 @@ export default function NewItemPage() {
)}
</div>
)}
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
@@ -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" && (
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { 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"
@@ -14,9 +14,8 @@ import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { 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,7 +23,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -104,6 +103,7 @@ export default function ItemsPage() {
<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">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -208,6 +208,7 @@ export default function ItemsPage() {
{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>
@@ -223,16 +224,17 @@ export default function ItemsPage() {
<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">
<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">
<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"
@@ -258,7 +260,31 @@ export default function ItemsPage() {
</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>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
@@ -265,17 +265,10 @@ export default function NewGrnPage() {
// branch to pin to and the Admin must pick one explicitly for this GRN.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
// The branch this GRN is being raised for — no longer used to filter warehouses
// (warehouses are shared infrastructure across branches), only to scope the document itself.
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? (warehouses ?? []) : (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId]
)
// Switching branch can strip the currently-picked warehouse out of the visible set —
// clear it rather than silently submitting a warehouse from the previous branch.
useEffect(() => {
setWarehouseId((prev) => (prev !== null && !visibleWarehouses.some((w) => w.warehouseId === prev) ? null : prev))
}, [visibleWarehouses])
const visibleWarehouses = warehouses ?? []
function switchMode(next: Mode) {
setMode(next)
@@ -459,6 +452,10 @@ export default function NewGrnPage() {
setHeaderError("Select a purchase order.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this GRN.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
@@ -513,6 +510,7 @@ export default function NewGrnPage() {
poId: mode === "po" ? poId : null,
vendorId: mode === "direct" ? vendorId : null,
warehouseId: warehouseId as number,
branchId: effectiveBranchId ?? 0,
lines: payloadLines,
})
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
@@ -1037,7 +1035,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">
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Eye, PackageSearch, Plus, Search } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
@@ -28,7 +27,7 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 5
export default function GrnListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -235,7 +234,39 @@ export default function GrnListPage() {
</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>
@@ -222,7 +222,7 @@ export default function BundleTemplatesPage() {
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-40 text-sm">
<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">
@@ -288,7 +288,7 @@ export default function BundleTemplatesPage() {
<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">{t.description ?? "—"}</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">
@@ -306,7 +306,7 @@ export default function NewFreeIssuePage() {
<TableBody>
{activeLines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
@@ -315,7 +315,7 @@ export default function NewFreeIssuePage() {
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-44 text-sm">
<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">
@@ -324,7 +324,7 @@ export default function NewFreeIssuePage() {
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right text-xs">editing</TableCell>
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
</TableRow>
))}
</TableBody>
@@ -365,7 +365,7 @@ export default function NewFreeIssuePage() {
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
@@ -544,7 +544,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">
@@ -555,7 +555,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"
@@ -568,11 +568,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>
@@ -598,7 +598,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}
@@ -2,14 +2,13 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
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"
@@ -21,7 +20,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -206,11 +205,11 @@ export default function SalesInvoicesPage() {
</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">
@@ -280,9 +279,22 @@ export default function SalesInvoicesPage() {
</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>
)}
</>
@@ -111,7 +111,7 @@ export default function SalesReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</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>
))}
@@ -25,12 +25,11 @@ import {
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
const PAGE_SIZE = 10
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
function deriveCode(name: string): string {
@@ -280,7 +279,31 @@ export default function RolesPage() {
</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>
)
}
@@ -28,13 +28,12 @@ import {
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 = 25
const PAGE_SIZE = 10
export default function UsersPage() {
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
@@ -456,7 +455,31 @@ export default function UsersPage() {
</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>
)
}
@@ -101,7 +101,7 @@ export default function AdjustmentsListPage() {
<TableCell className="px-3 py-3.5">
<AdjustmentStatusBadge status={a.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -142,7 +142,7 @@ export default function CountDetailPage() {
<TableBody>
{count.lines.map((line) => (
<TableRow key={line.countLineId}>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status === "Draft" ? (
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ScrollText } from "lucide-react"
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
@@ -13,15 +13,15 @@ import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
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 { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 25
const PAGE_SIZE = 10
export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -197,7 +197,7 @@ export default function StockLedgerPage() {
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5 text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
</TableRow>
@@ -206,7 +206,33 @@ export default function StockLedgerPage() {
</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>
@@ -152,7 +152,7 @@ export default function TransferDetailPage() {
<TableBody>
{transfer.lines.map((line) => (
<TableRow key={line.transferLineId}>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
@@ -155,7 +155,7 @@ function ValuationContent() {
<TableBody>
{valuation.layers.map((layer) => (
<TableRow key={layer.layerId}>
<TableCell className="px-3 py-3.5">{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">#{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
+35 -4
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
@@ -25,7 +25,6 @@ import {
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"
@@ -33,7 +32,7 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25
const PAGE_SIZE = 5
export default function VendorsPage() {
const [vendors, setVendors] = useState<Vendor[] | null>(null)
@@ -354,7 +353,39 @@ export default function VendorsPage() {
</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>
+23 -24
View File
@@ -59,11 +59,10 @@
--card-foreground: oklch(0.15 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.586 0.222 17.585);
--primary: oklch(0.463 0.258 262.881);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.969 0.015 12.422);
--secondary-foreground: oklch(0.514 0.198 16.935);
--secondary: oklch(0.92 0.055 264.901);
--secondary-foreground: oklch(0.463 0.258 262.881);
--muted: oklch(0.93 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.55 0.15 205.5);
@@ -71,12 +70,12 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.01 0);
--input: oklch(0.945 0.01 0);
--ring: oklch(0.586 0.222 17.585);
--ring: oklch(0.463 0.258 262.881);
--success: oklch(0.582 0.183 142.495);
--warning: oklch(0.787 0.141 72.031);
--error: oklch(0.577 0.245 27.325);
--info: oklch(0.631 0.215 259.262);
--chart-1: oklch(0.586 0.222 17.585);
--chart-1: oklch(0.463 0.258 262.881);
--chart-2: oklch(0.631 0.215 259.262);
--chart-3: oklch(0.582 0.183 142.495);
--chart-4: oklch(0.787 0.141 72.031);
@@ -84,12 +83,12 @@
--radius: 0.5rem;
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.586 0.222 17.585);
--sidebar-primary: oklch(0.463 0.258 262.881);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.969 0.015 12.422);
--sidebar-accent-foreground: oklch(0.514 0.198 16.935);
--sidebar-accent: oklch(0.92 0.055 264.901);
--sidebar-accent-foreground: oklch(0.463 0.258 262.881);
--sidebar-border: oklch(0.925 0.01 0);
--sidebar-ring: oklch(0.586 0.222 17.585);
--sidebar-ring: oklch(0.463 0.258 262.881);
}
.dark {
@@ -99,9 +98,9 @@
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.175 0 0);
--popover-foreground: oklch(0.95 0 0);
--primary: oklch(0.645 0.215 16.439);
--primary: oklch(0.68 0.186 265.215);
--primary-foreground: oklch(0.125 0 0);
--secondary: oklch(0.28 0.06 16.439);
--secondary: oklch(0.28 0.04 264.901);
--secondary-foreground: oklch(0.95 0 0);
--muted: oklch(0.275 0.02 0);
--muted-foreground: oklch(0.708 0 0);
@@ -110,24 +109,24 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.25 0.01 0);
--input: oklch(0.2 0.02 0);
--ring: oklch(0.645 0.215 16.439);
--ring: oklch(0.68 0.186 265.215);
--success: oklch(0.65 0.15 142.495);
--warning: oklch(0.85 0.12 72.031);
--error: oklch(0.704 0.191 22.216);
--info: oklch(0.75 0.18 259.262);
--chart-1: oklch(0.645 0.215 16.439);
--chart-1: oklch(0.68 0.186 265.215);
--chart-2: oklch(0.75 0.18 259.262);
--chart-3: oklch(0.65 0.15 142.495);
--chart-4: oklch(0.85 0.12 72.031);
--chart-5: oklch(0.704 0.191 22.216);
--sidebar: oklch(0.125 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.645 0.215 16.439);
--sidebar-primary: oklch(0.68 0.186 265.215);
--sidebar-primary-foreground: oklch(0.95 0 0);
--sidebar-accent: oklch(0.28 0.06 16.439);
--sidebar-accent: oklch(0.28 0.04 264.901);
--sidebar-accent-foreground: oklch(0.95 0 0);
--sidebar-border: oklch(0.25 0.01 0);
--sidebar-ring: oklch(0.645 0.215 16.439);
--sidebar-ring: oklch(0.68 0.186 265.215);
}
/* Vibrant — the "System" toggle option. Light content area (background,
@@ -142,10 +141,10 @@
--card-foreground: oklch(0.2 0.02 265);
--popover: oklch(0.995 0.002 265);
--popover-foreground: oklch(0.2 0.02 265);
--primary: oklch(0.586 0.222 17.585);
--primary: oklch(0.55 0.2 275);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.955 0.02 17.585);
--secondary-foreground: oklch(0.514 0.198 16.935);
--secondary: oklch(0.93 0.02 275);
--secondary-foreground: oklch(0.32 0.15 275);
--muted: oklch(0.94 0.006 265);
--muted-foreground: oklch(0.48 0.02 265);
--accent: oklch(0.55 0.14 210);
@@ -153,24 +152,24 @@
--destructive: oklch(0.58 0.22 25);
--border: oklch(0.88 0.012 265);
--input: oklch(0.92 0.01 265);
--ring: oklch(0.586 0.222 17.585);
--ring: oklch(0.55 0.2 275);
--success: oklch(0.55 0.15 150);
--warning: oklch(0.72 0.15 80);
--error: oklch(0.58 0.22 25);
--info: oklch(0.55 0.14 210);
--chart-1: oklch(0.586 0.222 17.585);
--chart-1: oklch(0.55 0.2 275);
--chart-2: oklch(0.55 0.14 210);
--chart-3: oklch(0.55 0.15 150);
--chart-4: oklch(0.72 0.15 80);
--chart-5: oklch(0.58 0.22 25);
--sidebar: oklch(0.18 0.02 265);
--sidebar-foreground: oklch(0.96 0.005 265);
--sidebar-primary: oklch(0.645 0.215 16.439);
--sidebar-primary: oklch(0.64 0.19 275);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.28 0.03 265);
--sidebar-accent-foreground: oklch(0.96 0.005 265);
--sidebar-border: oklch(0.26 0.025 265);
--sidebar-ring: oklch(0.645 0.215 16.439);
--sidebar-ring: oklch(0.64 0.19 275);
}
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
+4 -5
View File
@@ -85,8 +85,7 @@ function LoginForm() {
</div>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1>
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
{sessionNotice ? (
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
@@ -147,7 +146,7 @@ function LoginForm() {
</div>
<FieldError errors={[form.formState.errors.password]} />
<div className="flex justify-end mt-1">
<Link href="/login/forgot" className="text-sm font-medium text-primary hover:text-primary/80 transition-colors">
<Link href="/login/forgot" className="text-sm font-medium text-foreground hover:text-foreground/70 transition-colors">
Forgot password?
</Link>
</div>
@@ -162,7 +161,7 @@ function LoginForm() {
<Button
type="submit"
className="w-full mt-8 h-12 text-base font-semibold rounded-full"
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
@@ -177,7 +176,7 @@ function LoginForm() {
<div className="mt-12 text-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
Don&apos;t have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
</p>
</div>
</main>
@@ -42,6 +42,7 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
@@ -76,15 +77,7 @@ const navItems: {
expandOnly?: boolean
icon: LucideIcon
chevron?: boolean
children?: {
title: string
code: string
href: string
icon: LucideIcon
/** Extra codes that also grant visibility for this row (e.g. a merged Brand/UOM
* tab page stays visible to a role that only has the UOM permission). */
altCodes?: string[]
}[]
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
}[] = [
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
{
@@ -96,7 +89,8 @@ const navItems: {
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
],
},
{
@@ -450,17 +444,10 @@ export function AppSidebar() {
const visibleItems = loading
? []
: navItems
.filter(
(item) =>
bypassCodes.has(item.code) ||
navCodes.includes(item.code) ||
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)
.filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
.map((item) => ({
...item,
children: bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
}))
// Close mobile menu on route change
@@ -112,9 +112,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products") return "Items"
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
@@ -48,7 +48,7 @@ export function ThemeToggle({ className }: { className?: string }) {
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors",
isActive
? isVibrant
? "bg-linear-to-br from-sidebar-primary to-primary text-primary-foreground shadow-sm"
? "bg-linear-to-br from-indigo-500 to-violet-600 text-white shadow-sm"
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
: "text-muted-foreground hover:text-foreground"
)}
+2 -2
View File
@@ -8,9 +8,9 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary",
default: "bg-black text-white hover:bg-black/90 active:bg-black",
outline:
"border-primary bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
"border-black bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
@@ -78,11 +78,11 @@ export function Combobox<Value>({
<ComboboxPrimitive.Item
key={String(item.value)}
value={item.value}
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-secondary data-highlighted:text-secondary-foreground"
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
>
{item.label}
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<CheckIcon className="size-4 text-primary" />
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)}
@@ -88,7 +88,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-popup-open:bg-secondary data-popup-open:text-secondary-foreground data-open:bg-secondary data-open:text-secondary-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -1,89 +0,0 @@
"use client"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { PaginationMeta } from "@/types/common"
interface PaginationFooterProps {
pagination: PaginationMeta
onPageChange: (page: number) => void
}
/** First, last, current ±1, and ellipses for the gaps — keeps the row from overflowing
* on tables with many pages while still surfacing the ends. */
function pageItems(current: number, total: number): (number | "ellipsis")[] {
const items: (number | "ellipsis")[] = [1]
if (current - 1 > 2) items.push("ellipsis")
for (let p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) items.push(p)
if (current + 1 < total - 1) items.push("ellipsis")
if (total > 1) items.push(total)
return items
}
/** Server-paginated table footer: "X – Y of Z" plus numbered page links, built on the
* shadcn-style pagination primitives. Use for any list page driven by a `PaginationMeta`
* from the API (as opposed to `DataTable`'s own client-side pagination). */
export function PaginationFooter({ pagination, onPageChange }: PaginationFooterProps) {
const { page, pageSize, totalItems, totalPages } = pagination
if (totalPages <= 1) return null
const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, totalItems)
function go(e: React.MouseEvent, target: number) {
e.preventDefault()
if (target >= 1 && target <= totalPages) onPageChange(target)
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{from} – {to} of {totalItems}
</p>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text=""
onClick={(e) => go(e, page - 1)}
aria-disabled={page === 1}
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
{pageItems(page, totalPages).map((item, i) =>
item === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink href="#" isActive={item === page} onClick={(e) => go(e, item)}>
{item}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
text=""
onClick={(e) => go(e, page + 1)}
aria-disabled={page === totalPages}
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}
@@ -1,36 +0,0 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props<unknown>) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props<unknown>) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"relative flex size-4 shrink-0 items-center justify-center rounded-full border border-input transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 data-checked:border-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-item-indicator"
className="flex items-center justify-center after:size-2 after:rounded-full after:bg-primary"
/>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+2 -2
View File
@@ -150,7 +150,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-violet-100 focus:text-violet-900 dark:focus:bg-violet-500/25 dark:focus:text-violet-200 not-data-[variant=destructive]:focus:**:text-violet-900 dark:not-data-[variant=destructive]:focus:**:text-violet-200 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
@@ -163,7 +163,7 @@ function SelectItem({
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none text-primary" />
<CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
@@ -71,26 +71,26 @@ export function StatCard({
return (
<div
className={cn(
"flex flex-col gap-2 rounded-xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
"flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-base font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-3.5 text-primary" />
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-5 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-xs">
<div className="flex items-center gap-1 text-sm">
<span
className={cn(
"font-semibold",
+1 -7
View File
@@ -1,14 +1,13 @@
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
// `list` also backs the GRN/PO line UOM picker.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { EntityStatus, PagedResponse } from "@/types/common"
import { PagedResponse } from "@/types/common"
import { CreateUomRequest, Uom } from "@/types/master-data"
export interface ListUomsParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
sort?: string
}
@@ -20,9 +19,4 @@ export const uomsApi = {
create(request: CreateUomRequest): Promise<Uom> {
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
},
/** Deactivate/reactivate. There is no DELETE — masters are never hard-deleted. */
updateStatus(uomId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/uoms/${uomId}/status`, { method: "PATCH", body: { status } })
},
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

+4
View File
@@ -61,6 +61,8 @@ export interface CreateGrnRequest {
/** Required when poId is null (direct/emergency receipt, FR-GRN-02). */
vendorId?: number | null
warehouseId: number
/** Owning branch — warehouses are shared across branches, so this is what actually scopes the GRN. */
branchId: number
lines: CreateGrnLineInput[]
}
@@ -96,6 +98,7 @@ export interface Grn {
poId: number | null
vendorId: number
warehouseId: number
branchId: number
status: GrnStatus
createdBy: number
createdAt: string
@@ -116,6 +119,7 @@ export interface GrnSummary {
poId: number | null
vendorId: number
warehouseId: number
branchId: number
status: GrnStatus
createdBy: number
createdAt: string
-3
View File
@@ -184,9 +184,6 @@ export type UpdateVendorRequest = CreateVendorRequest
export interface Uom {
uomId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateUomRequest {
-19
View File
@@ -24,25 +24,6 @@ Principles:
- **Keep components thin;** put data-fetching and derived state in hooks, request/response shaping in the API client.
- **One typed client method per endpoint**, returning the DTO type from `types/`.
### 1.1 Design tokens (colour theme)
All colour comes from CSS custom properties in `app/globals.css` (`:root`, `.dark`, `.vibrant`), consumed as Tailwind utilities (`bg-primary`, `text-primary`, `bg-secondary`, …) via the `@theme inline` mapping — **never hardcode a hex/oklch value in a component.** To restyle the brand accent, change the token values in `globals.css`; every component that uses `bg-primary`/`text-primary`/`ring` picks it up automatically across all three themes (light/dark/vibrant).
> **2026-09-10 — brand accent switched to crimson.** `--primary` (and `--ring`, `--chart-1`, `--sidebar-primary`, `--sidebar-accent`) changed from blue/violet to a crimson-red (`oklch(0.586 0.222 17.585)` light/vibrant, `oklch(0.645 0.215 16.439)` dark) to match the reference brand look, purely a token-value change — no component structure, layout, or architecture changed. `components/ui/button.tsx`'s `default` variant now reads `bg-primary`/`text-primary-foreground` (was hardcoded `bg-black`/`text-white`), so this is the first theme change to actually flow through the shared Button rather than being fought by a one-off override. The Login screen (`app/login/page.tsx`) was updated to stop overriding the button with `bg-foreground` and instead take the shared `default` variant, and its "Forgot password?" / "Create an account" links now use `text-primary` instead of `text-foreground` to read as accent links. Same date: `components/ui/select.tsx` (`SelectItem`) and `components/ui/combobox.tsx` had a hardcoded `violet-*` highlight/check-mark color that ignored the theme entirely — fixed to token-driven colours, then aligned with the sidebar look (below).
> **2026-09-10 — dropdown/select/combobox highlight matches the sidebar's active-item colour.** `DropdownMenuItem`/`DropdownMenuSubTrigger`/`DropdownMenuCheckboxItem`/`DropdownMenuRadioItem` (`dropdown-menu.tsx`), `SelectItem` (`select.tsx`), and the combobox item (`combobox.tsx`) all switched their highlighted/focused state from `bg-accent`/`text-accent-foreground` (a separate teal token, unrelated to the brand colour) to `bg-secondary`/`text-secondary-foreground` — the same soft-crimson-tint token the sidebar's active nav item already uses for its background/text. Result: hovering or keyboard-focusing any menu, select, or combobox row now highlights in the same soft-crimson tone as the active sidebar item, in every theme (light/dark/vibrant), since both read the same token pair. `--accent` itself is untouched and still used elsewhere (e.g. `ghost` button hover) as a neutral highlight.
> **2026-09-10 — theme-toggle "Vibrant" segment matches the sidebar/brand colour.** `components/theme-toggle.tsx`'s active "Vibrant" (monitor icon) segment was hardcoded to `bg-linear-to-br from-indigo-500 to-violet-600` — the only spot in the toggle not driven by a token, so it kept showing indigo/violet regardless of the brand colour. Changed to `from-sidebar-primary to-primary` with `text-primary-foreground`, so the active pill now renders a crimson gradient matching the sidebar's active-item colour, and will track any future `--primary`/`--sidebar-primary` change automatically.
> **2026-09-10 — Hexa ERP logo re-coloured, background removed.** `public/images/hexa-erp-logo.png` (used by both `AppSidebar.tsx`'s header and `app/login/page.tsx`) was a raster image with a baked-in near-white background and blue/navy/gray brand colours — not something a CSS/token change can touch, since it's pixels, not markup. Reprocessed with a one-off Node/`sharp` script (not committed — this was an asset edit, not a code change) that made the near-white background fully transparent (with soft alpha at the antialiased edges, not a hard cutout) and remapped the blue/navy hues to the same crimson used by `--primary`/`--sidebar-primary`, while leaving the "ERP" wordmark and its hexagon a neutral gray. The original is kept alongside as `hexa-erp-logo.png.bak` in case the brand mark itself needs to be redone by a designer later. If the logo is ever replaced, prefer sourcing a transparent-background asset directly rather than re-running the extraction script, which is a best-effort colour/alpha heuristic, not a general tool.
> **2026-09-10 — dashboard stat cards sized down.** `components/ui/stat-card.tsx` (the top row of the dashboard: Low Stock Alerts, Stock On-Hand, etc.) had generous spacing (`p-5`, `gap-3`, `text-base` label, `size-9` icon, `text-xl` value) that read as oversized once the cards' third row (`delta`/`trend`, both optional props the dashboard never passes) was empty — most of a card's height was whitespace. Tightened to `p-4`, `gap-2`, `text-sm` label, `size-7` icon, `text-lg` value, `text-xs` delta row — same content, same props/behaviour, just denser. `StatCard` has exactly one call site (`app/dashboard/page.tsx`), so this didn't need reconciling against any other screen. Same date: the grid wrapping those cards went from `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` (wrapped to two rows) to `grid-cols-2 sm:grid-cols-4 lg:grid-cols-7` (all 7 in one row), then the **"Pending Approval POs" card was removed** from the dashboard entirely (`app/dashboard/page.tsx`) and the grid settled at `grid-cols-2 sm:grid-cols-3 lg:grid-cols-6` for the remaining 6 — a deliberate content + layout call, not a bug fix. `stats.pendingApprovalPurchaseOrders` and the `DashboardStats` type are untouched (backend/API contract, out of scope for a UI-only change) — only the card and its now-unused `Clock` icon import were removed from the page.
> **2026-09-10 — the two dashboard chart panels sit side by side.** "Stock Valuation by Warehouse" (bar chart) and "Stock Movement Trend" (line chart) on `app/dashboard/page.tsx` were two full-width `rounded-2xl` panels stacked vertically. Wrapped both in a `grid grid-cols-1 lg:grid-cols-2 gap-4` container so they sit in one row at `lg` and above, and stack on narrower screens — each panel's own markup/content is unchanged, only the wrapping grid was added.
> **2026-09-10 — "Stock Valuation by Warehouse" bar colour matches the brand crimson.** The bar dataset's `backgroundColor` was a hardcoded indigo `#6366f1`, left over from before the crimson theme change and not updated with it. Changed to `#E11D48` (the same crimson as `--primary`). `components/ui/bar-chart.tsx`/`line-chart.tsx` are thin Chart.js wrappers that take a plain CSS colour string per dataset — Canvas rendering can't reliably resolve the app's `oklch()` custom properties, so (matching the Line chart's existing "In"/"Out" green/red, also literal hex) the call site passes a literal hex kept in sync with the token by hand rather than a `var(--primary)` reference. The Line chart's green ("In") / red ("Out") were left as-is: those are semantic (stock direction), not brand colour, and forcing them to crimson would remove the in/out distinction.
> **2026-09-10 — login-page illustration's UI-mockup panels recoloured to crimson.** `public/images/loginerps.png` (light mode) and `public/images/whithoutbackground.png` (dark mode, `app/login/page.tsx`'s two side-by-side hero images) are photorealistic scenes — two people at a table plus three floating UI-mockup panels ("Inventory Management", "Order Processing", "Integrated Analytics") in blue/teal. Unlike the flat-vector logo, a global hue-shift here would also discolour the people (navy suits/hair share the same blue hue range as the panel chrome — verified by direct HSL sampling, not assumption), so the recolour script is restricted to three hand-verified pixel rectangles covering just the panels, and only remaps hue 180-230° pixels with real saturation/lightness (`spread ≥ 40`, `s ≥ 0.25`, `0.2 ≤ l ≤ 0.85`) to crimson, blending by saturation so text/borders/antialiasing aren't speckled. Greens (the "Delivered" status pill, success ring, checkmarks) are left alone — same semantic-colour reasoning as the dashboard charts, above. `whithoutbackground.png` additionally got the same white-to-transparent treatment as the logo (it turned out to be a flat white background despite the filename, not real alpha) in the same pass. Both originals are kept as `loginerps.png.bak` / `whithoutbackground.png.bak`. This is a best-effort raster edit (not a general tool, and not committed as a script) — if a designer ever redoes this illustration, recolour it at the source rather than re-running the heuristic.
---
## 2. User flows