Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 149b87bfeb |
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -26,4 +28,14 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,6 @@ 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,11 +17,6 @@ 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,11 +19,6 @@ 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,11 +16,6 @@ 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,11 +19,6 @@ 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; }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,4 +12,8 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -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, int BranchId, GrnStatus Status,
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, 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, int BranchId, GrnStatus Status,
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
@@ -81,9 +81,6 @@ 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, int BranchId,
|
||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
||||
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, int BranchId, SalesInvoiceType InvoiceType,
|
||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
@@ -65,9 +65,6 @@ 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();
|
||||
}
|
||||
@@ -76,9 +73,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -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 BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int AdjustmentId, string DocNo, int WarehouseId, 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 BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
@@ -31,9 +31,6 @@ 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();
|
||||
|
||||
@@ -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, int BranchId, CountType CountType, CountStatus Status,
|
||||
int CountId, string DocNo, int WarehouseId, 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, int BranchId, CountType CountType, CountStatus Status,
|
||||
int CountId, string DocNo, int WarehouseId, 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,9 +23,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -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 BranchId,
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
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 BranchId,
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||
@@ -42,9 +42,6 @@ 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,11 +1,17 @@
|
||||
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);
|
||||
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
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,13 +22,11 @@ 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,11 +30,6 @@ 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);
|
||||
@@ -52,7 +47,6 @@ 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,10 +21,7 @@ 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,11 +20,9 @@ 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,11 +20,9 @@ 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,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -13,5 +14,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260908101847_AddBranchIdToDocuments")]
|
||||
partial class AddBranchIdToDocuments
|
||||
[Migration("20260914061430_AddUomLifecycleColumnsLocal")]
|
||||
partial class AddUomLifecycleColumnsLocal
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -1450,9 +1450,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -1499,8 +1496,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("GrnId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4195,9 +4190,6 @@ 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");
|
||||
|
||||
@@ -4290,8 +4282,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("SalesInvoiceId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatorUserId");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
@@ -4872,9 +4862,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -4912,8 +4899,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("AdjustmentId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4976,9 +4961,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CountType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -5011,8 +4993,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("CountId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -5205,9 +5185,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -5238,8 +5215,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("TransferId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DestWarehouseId");
|
||||
@@ -5787,16 +5762,31 @@ 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);
|
||||
});
|
||||
|
||||
@@ -6408,12 +6398,6 @@ 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")
|
||||
@@ -6442,8 +6426,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
@@ -7178,12 +7160,6 @@ 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");
|
||||
@@ -7200,8 +7176,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -7467,12 +7441,6 @@ 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")
|
||||
@@ -7491,8 +7459,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
@@ -7536,12 +7502,6 @@ 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")
|
||||
@@ -7554,8 +7514,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
@@ -7663,12 +7621,6 @@ 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")
|
||||
@@ -7687,8 +7639,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("DestWarehouse");
|
||||
@@ -0,0 +1,61 @@
|
||||
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,9 +1447,6 @@ 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,8 +1493,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("GrnId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4192,9 +4187,6 @@ 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");
|
||||
|
||||
@@ -4287,8 +4279,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("SalesInvoiceId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatorUserId");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
@@ -4869,9 +4859,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -4909,8 +4896,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("AdjustmentId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4973,9 +4958,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CountType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -5008,8 +4990,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("CountId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -5202,9 +5182,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -5235,8 +5212,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("TransferId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DestWarehouseId");
|
||||
@@ -5784,16 +5759,31 @@ 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);
|
||||
});
|
||||
|
||||
@@ -6405,12 +6395,6 @@ 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")
|
||||
@@ -6439,8 +6423,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
@@ -7175,12 +7157,6 @@ 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");
|
||||
@@ -7197,8 +7173,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -7464,12 +7438,6 @@ 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")
|
||||
@@ -7488,8 +7456,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
@@ -7533,12 +7499,6 @@ 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")
|
||||
@@ -7551,8 +7511,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
@@ -7660,12 +7618,6 @@ 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")
|
||||
@@ -7684,8 +7636,6 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("DestWarehouse");
|
||||
|
||||
@@ -32,7 +32,6 @@ 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;
|
||||
@@ -42,7 +41,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, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
@@ -53,7 +52,6 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
@@ -107,15 +105,14 @@ 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);
|
||||
// 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 (branchId is not null) q = q.Where(a => a.Warehouse!.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.BranchId, a.ReasonCodeId, a.Status,
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -148,8 +145,6 @@ 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)
|
||||
@@ -174,7 +169,6 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = request.WarehouseId,
|
||||
BranchId = branchId,
|
||||
ReasonCodeId = request.ReasonCodeId.Value,
|
||||
Status = AdjustmentStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
@@ -202,7 +196,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
}
|
||||
|
||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, 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(),
|
||||
|
||||
@@ -34,7 +34,6 @@ 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;
|
||||
@@ -44,7 +43,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, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_counts = counts;
|
||||
@@ -56,7 +55,6 @@ public sealed class CountService : ICountService
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
@@ -110,14 +108,13 @@ 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);
|
||||
// Warehouses are shared across branches — the count carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
|
||||
if (branchId is not null) q = q.Where(c => c.Warehouse!.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.BranchId, c.CountType, c.Status,
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
|
||||
c.CreatedBy, c.CreatedAt, c.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -136,8 +133,6 @@ 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))
|
||||
@@ -158,7 +153,6 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = request.WarehouseId,
|
||||
BranchId = branchId,
|
||||
CountType = request.CountType,
|
||||
Status = CountStatus.Draft,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
@@ -228,7 +222,6 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = count.WarehouseId,
|
||||
BranchId = count.BranchId,
|
||||
ReasonCodeId = reason.ReasonCodeId,
|
||||
Status = AdjustmentStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
@@ -257,7 +250,7 @@ public sealed class CountService : ICountService
|
||||
}
|
||||
|
||||
private static CountDto Map(StockCount c) => new(
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
|
||||
c.CountId, c.DocNo, c.WarehouseId, 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());
|
||||
}
|
||||
|
||||
@@ -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.BranchId == branchId);
|
||||
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.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.BranchId == branchId);
|
||||
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
|
||||
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
|
||||
|
||||
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
|
||||
|
||||
@@ -38,7 +38,6 @@ 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;
|
||||
@@ -50,8 +49,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, IBranchConfigService branchConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
@@ -66,7 +65,6 @@ public sealed class GrnService : IGrnService
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
@@ -88,14 +86,15 @@ 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);
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
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.BranchId, g.Status,
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
|
||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||
.ToListAsync(ct);
|
||||
@@ -117,8 +116,6 @@ 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;
|
||||
@@ -214,7 +211,6 @@ public sealed class GrnService : IGrnService
|
||||
PoId = request.PoId,
|
||||
VendorId = vendorId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
BranchId = branchId,
|
||||
Status = GrnStatus.Draft,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -452,7 +448,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.BranchId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, 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,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
|
||||
@@ -6,6 +7,7 @@ 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, CancellationToken ct = default);
|
||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ 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;
|
||||
|
||||
@@ -34,7 +33,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, IBranchConfigService branchConfig, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_customers = customers;
|
||||
@@ -46,7 +45,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_mapping = mapping;
|
||||
_workflow = workflow;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -62,8 +60,9 @@ 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);
|
||||
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
||||
// 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);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
@@ -84,7 +83,6 @@ 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);
|
||||
@@ -98,7 +96,6 @@ 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,
|
||||
@@ -122,7 +119,6 @@ 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);
|
||||
@@ -205,5 +201,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.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||
x.WarehouseId, 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.BranchId,
|
||||
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
|
||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||
invoice.GlJournalNo, invoice.GlPostedAt,
|
||||
MapInvoiceTotals(invoice),
|
||||
|
||||
@@ -34,13 +34,11 @@ 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,
|
||||
IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_transfers = transfers;
|
||||
_warehouses = warehouses;
|
||||
@@ -48,7 +46,6 @@ public sealed class TransferService : ITransferService
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -65,14 +62,15 @@ 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);
|
||||
// Warehouses are shared across branches — the transfer carries its own owning BranchId.
|
||||
if (branchId is not null) q = q.Where(t => t.BranchId == branchId);
|
||||
// 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);
|
||||
|
||||
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.BranchId, t.Status,
|
||||
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
|
||||
t.CreatedBy, t.CreatedAt, t.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -92,7 +90,6 @@ 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);
|
||||
@@ -106,7 +103,6 @@ public sealed class TransferService : ITransferService
|
||||
DocNo = docNo,
|
||||
SrcWarehouseId = request.SrcWarehouseId,
|
||||
DestWarehouseId = request.DestWarehouseId,
|
||||
BranchId = branchId,
|
||||
Status = TransferStatus.Draft,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -231,7 +227,7 @@ public sealed class TransferService : ITransferService
|
||||
}
|
||||
|
||||
private static TransferDto Map(StockTransfer t) => new(
|
||||
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
|
||||
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, 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());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _uoms.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -28,11 +29,12 @@ 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))
|
||||
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -44,10 +46,20 @@ 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 };
|
||||
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
await _uoms.AddAsync(uom, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new UomDto(uom.UomId, uom.Name);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
|
||||
@@ -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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{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 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||
</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">{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 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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 text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{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 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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,11 +15,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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -137,16 +138,7 @@ export default function AttendanceBatchesPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const emptyForm: CreateEmployeeRequest = {
|
||||
employeeCode: "",
|
||||
@@ -308,16 +309,7 @@ export default function EmployeesPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -183,16 +184,7 @@ export default function LeaveRequestsPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,11 +15,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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -124,16 +125,7 @@ export default function PayrollRunsPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
const NONE = "__none__"
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
@@ -183,16 +184,7 @@ export default function DepartmentsPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
||||
|
||||
export default function DocumentTypesPage() {
|
||||
@@ -161,16 +162,7 @@ export default function DocumentTypesPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,11 +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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function LeaveTypesPage() {
|
||||
const [items, setItems] = useState<LeaveType[] | null>(null)
|
||||
@@ -164,16 +165,7 @@ export default function LeaveTypesPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function SalaryComponentsPage() {
|
||||
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
||||
@@ -161,16 +162,7 @@ export default function SalaryComponentsPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,11 +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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
export default function WorkShiftsPage() {
|
||||
@@ -207,16 +208,7 @@ export default function WorkShiftsPage() {
|
||||
</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))}>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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</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 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlertTriangle,
|
||||
BadgeDollarSign,
|
||||
Boxes,
|
||||
Clock,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
@@ -128,7 +127,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
@@ -137,9 +136,6 @@ 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>
|
||||
@@ -154,66 +150,68 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</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">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#E11D48",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {TREND_DAYS} days)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
<LineChart
|
||||
labels={movementTrend.labels}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#6366f1",
|
||||
},
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {TREND_DAYS} days)
|
||||
</h2>
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<LineChart
|
||||
labels={movementTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
@@ -243,9 +241,9 @@ export default function DashboardPage() {
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
@@ -260,11 +258,11 @@ export default function DashboardPage() {
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
<TableCell className="text-right">
|
||||
{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 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3">{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 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3">{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,12 +2,13 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
||||
import { CheckCircle2, 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"
|
||||
@@ -19,7 +20,7 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = PurchaseInvoiceStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
const tabs: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
@@ -198,10 +199,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 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{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 text-muted-foreground">{row.totals.vatTotal.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 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">
|
||||
@@ -271,22 +272,9 @@ export default function PurchaseInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
{pagination && (
|
||||
<div className="border-t px-4 py-4">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||
import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
@@ -22,7 +23,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
@@ -260,31 +261,7 @@ export default function PurchaseOrdersListPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function PurchaseReturnsListPage() {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
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"
|
||||
@@ -17,7 +18,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = RequisitionStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function RequisitionsListPage() {
|
||||
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
|
||||
@@ -114,36 +115,14 @@ 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 && 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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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"
|
||||
@@ -425,22 +426,7 @@ export default function ProductionRunsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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"
|
||||
|
||||
@@ -286,26 +287,7 @@ export default function ProductionTemplatesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,223 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Plus, Ruler, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateBrandName } from "@/lib/validations/master-data"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand } from "@/types/master-data"
|
||||
import { Brand, Uom } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } 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"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "brandId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type Tab = "brand" | "uom"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
// 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
|
||||
|
||||
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 [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, status])
|
||||
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)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
brandsApi
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setBrands(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setBrands(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, status, page])
|
||||
useEffect(load, [])
|
||||
|
||||
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() {
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateBrandName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
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)
|
||||
const result = await brandsApi.create({ name })
|
||||
toast.success("Brand created", result.data.name)
|
||||
setName("")
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err))
|
||||
toast.error("Could not create brand", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(brand: Brand) {
|
||||
async function handleToggleActive(brand: Brand) {
|
||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||
setDeletingId(brand.brandId)
|
||||
setTogglingId(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 {
|
||||
setDeletingId(null)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
|
||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="brand-name">Name</FieldLabel>
|
||||
<Input id="brand-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search brands…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search brands"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New brand name, e.g. Bosch"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New brand name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add Brand
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && brands === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
<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>
|
||||
)}
|
||||
@@ -225,162 +195,171 @@ export default function BrandsPage() {
|
||||
{!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">
|
||||
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
|
||||
</p>
|
||||
<p className="text-base text-muted-foreground">No brands yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedBrands!.map((b) => (
|
||||
<TableRow key={b.brandId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${b.name}`}
|
||||
onClick={() => openEditDialog(b)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Deactivate, not delete: the API has no DELETE for any master
|
||||
(FR-MD-08) — records referenced by transactions must survive. */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
|
||||
disabled={deletingId === b.brandId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={b.status === "Active" ? "destructive" : "success"}
|
||||
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
|
||||
description={
|
||||
b.status === "Active"
|
||||
? "The brand stays on existing items but cannot be assigned to new ones."
|
||||
: "The brand becomes selectable again."
|
||||
}
|
||||
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(b)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div 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"
|
||||
>
|
||||
<Checkbox
|
||||
checked={b.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(b)}
|
||||
disabled={togglingId === b.brandId}
|
||||
/>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</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 SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New UOM name, e.g. Box-12"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New UOM name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add UOM
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!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"
|
||||
>
|
||||
<Checkbox
|
||||
checked={u.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(u)}
|
||||
disabled={togglingId === u.uomId}
|
||||
/>
|
||||
<span className="font-medium">{u.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function CategorySubCategoriesPage() {
|
||||
<TableBody>
|
||||
{subCategories.map((s) => (
|
||||
<TableRow key={s.subCategoryId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
@@ -192,7 +192,7 @@ export default function CategorySubCategoriesPage() {
|
||||
{s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, 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 } from "@/types/master-data"
|
||||
import { Category, ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -17,6 +18,7 @@ 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"
|
||||
@@ -26,7 +28,7 @@ type SortOrder = "asc" | "desc"
|
||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
@@ -48,6 +50,36 @@ 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)
|
||||
@@ -247,13 +279,14 @@ 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 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
@@ -266,17 +299,23 @@ export default function CategoriesPage() {
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Subcategories are their own resource now, not a nested tree. */}
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
{/* Subcategories are their own resource now, not a nested tree.
|
||||
Hidden while the feature is switched off in Product Configuration. */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -321,39 +360,7 @@ export default function CategoriesPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function ItemTypesPage() {
|
||||
<TableBody>
|
||||
{itemTypes.map((t) => (
|
||||
<TableRow key={t.itemTypeId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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,6 +42,7 @@ 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"
|
||||
@@ -720,136 +721,122 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.sellingPricesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
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 max-w-xl flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Bulk default flat price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={defaultFlatPrice}
|
||||
onChange={(e) => setDefaultFlatPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{config?.sellingPricesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const raw = defaultFlatPrice.trim()
|
||||
if (!raw) return
|
||||
setPricesByKey((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const variant of variants) {
|
||||
if (next[variant.key] == null || next[variant.key].trim() === "") {
|
||||
next[variant.key] = raw
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
|
||||
<RadioGroup
|
||||
value={priceMode}
|
||||
onValueChange={(v) => setPriceMode(v as typeof priceMode)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
Fill blank flat prices
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This copies the same value into blank flat-price cells only.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{priceMode === "fixed" && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Bulk default flat price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={defaultFlatPrice}
|
||||
onChange={(e) => setDefaultFlatPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const raw = defaultFlatPrice.trim()
|
||||
if (!raw) return
|
||||
setPricesByKey((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const variant of variants) {
|
||||
if (next[variant.key] == null || next[variant.key].trim() === "") {
|
||||
next[variant.key] = raw
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
Fill blank flat prices
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This copies the same value into blank flat-price cells only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mark whether every generated variant is sold under warranty.
|
||||
</p>
|
||||
</div>
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mark whether every generated variant is sold under warranty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWarranty("NonWarranty")
|
||||
setWarrantyPeriodMonths(null)
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Non-Warranty
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarranty("Warranty")}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Warranty
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{warranty === "Warranty" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Warranty period</Label>
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={warrantyPeriodMonths}
|
||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||
<RadioGroup
|
||||
value={warranty}
|
||||
onValueChange={(v) => {
|
||||
setWarranty(v as typeof warranty)
|
||||
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{months} months
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="NonWarranty" />
|
||||
<span className="text-base font-medium">Non-Warranty</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="Warranty" />
|
||||
<span className="text-base font-medium">Warranty</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{warranty === "Warranty" && (
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
<Label>Warranty period +</Label>
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={warrantyPeriodMonths}
|
||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{months} months
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -888,7 +875,7 @@ export default function NewItemPage() {
|
||||
key={t.itemTypeId}
|
||||
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||
blocked ? "opacity-50" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
@@ -1047,7 +1034,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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-2.5">
|
||||
{contentLabelFor(variant)}
|
||||
</TableCell>
|
||||
{priceMode === "fixed" && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
||||
import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -14,8 +14,9 @@ import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { 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"
|
||||
@@ -23,7 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type TrackingFilter = TrackingMode | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function ItemsPage() {
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
@@ -103,7 +104,6 @@ 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,7 +208,6 @@ 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>
|
||||
@@ -224,17 +223,16 @@ 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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{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"
|
||||
@@ -260,31 +258,7 @@ export default function ItemsPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Ruler } from "lucide-react"
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
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"
|
||||
// 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 { 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"
|
||||
useEffect(() => {
|
||||
router.replace("/dashboard/products/brands?tab=uom")
|
||||
}, [router])
|
||||
|
||||
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>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
@@ -265,10 +265,17 @@ 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 = warehouses ?? []
|
||||
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])
|
||||
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next)
|
||||
@@ -452,10 +459,6 @@ 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
|
||||
@@ -510,7 +513,6 @@ 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.`)
|
||||
@@ -1035,7 +1037,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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3 align-top text-sm">
|
||||
{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 { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
|
||||
import { Eye, PackageSearch, Plus, Search } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -27,7 +28,7 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
type StatusFilter = GrnStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function GrnListPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
@@ -234,39 +235,7 @@ export default function GrnListPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function BundleTemplatesPage() {
|
||||
placeholder="Search item…"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
|
||||
<TableCell className="px-4 py-2 min-w-40 text-sm">
|
||||
{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 text-muted-foreground">{t.description ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{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 text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-4 py-2 text-xs">{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 text-muted-foreground">
|
||||
<TableCell className="px-4 py-2 min-w-44 text-sm">
|
||||
{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 text-muted-foreground">editing</TableCell>
|
||||
<TableCell className="px-4 py-2 text-right text-xs">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 text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-4 py-2 text-xs">{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 text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-sm">{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 text-muted-foreground">—</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-sm">—</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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3 align-top text-right text-sm">
|
||||
{bundleFreeQty || "—"}
|
||||
</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 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 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 text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Combobox
|
||||
items={lineComboOptions}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||
import { CheckCircle2, 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"
|
||||
@@ -20,7 +21,7 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = SalesInvoiceStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 25
|
||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
@@ -205,11 +206,11 @@ export default function SalesInvoicesPage() {
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</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">{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-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 text-muted-foreground">-{row.totals.discountTotal.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 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">
|
||||
@@ -279,22 +280,9 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
{pagination && (
|
||||
<div className="border-t px-4 py-4">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function SalesReturnsListPage() {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -25,11 +25,12 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||
function deriveCode(name: string): string {
|
||||
@@ -279,31 +280,7 @@ export default function RolesPage() {
|
||||
</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))}>
|
||||
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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function UsersPage() {
|
||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
@@ -455,31 +456,7 @@ export default function UsersPage() {
|
||||
</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))}>
|
||||
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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</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 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{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 { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
import { 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 = 10
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
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 text-muted-foreground">
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -206,33 +206,7 @@ export default function StockLedgerPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -25,6 +25,7 @@ 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"
|
||||
@@ -32,7 +33,7 @@ import { toast } from "@/components/ui/toast"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default function VendorsPage() {
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
@@ -353,39 +354,7 @@ export default function VendorsPage() {
|
||||
</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>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -59,10 +59,11 @@
|
||||
--card-foreground: oklch(0.15 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.15 0 0);
|
||||
--primary: oklch(0.463 0.258 262.881);
|
||||
|
||||
--primary: oklch(0.586 0.222 17.585);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.92 0.055 264.901);
|
||||
--secondary-foreground: oklch(0.463 0.258 262.881);
|
||||
--secondary: oklch(0.969 0.015 12.422);
|
||||
--secondary-foreground: oklch(0.514 0.198 16.935);
|
||||
--muted: oklch(0.93 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.55 0.15 205.5);
|
||||
@@ -70,12 +71,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.463 0.258 262.881);
|
||||
--ring: oklch(0.586 0.222 17.585);
|
||||
--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.463 0.258 262.881);
|
||||
--chart-1: oklch(0.586 0.222 17.585);
|
||||
--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);
|
||||
@@ -83,12 +84,12 @@
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.98 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
--sidebar-primary: oklch(0.463 0.258 262.881);
|
||||
--sidebar-primary: oklch(0.586 0.222 17.585);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.92 0.055 264.901);
|
||||
--sidebar-accent-foreground: oklch(0.463 0.258 262.881);
|
||||
--sidebar-accent: oklch(0.969 0.015 12.422);
|
||||
--sidebar-accent-foreground: oklch(0.514 0.198 16.935);
|
||||
--sidebar-border: oklch(0.925 0.01 0);
|
||||
--sidebar-ring: oklch(0.463 0.258 262.881);
|
||||
--sidebar-ring: oklch(0.586 0.222 17.585);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -98,9 +99,9 @@
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.175 0 0);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
--primary: oklch(0.68 0.186 265.215);
|
||||
--primary: oklch(0.645 0.215 16.439);
|
||||
--primary-foreground: oklch(0.125 0 0);
|
||||
--secondary: oklch(0.28 0.04 264.901);
|
||||
--secondary: oklch(0.28 0.06 16.439);
|
||||
--secondary-foreground: oklch(0.95 0 0);
|
||||
--muted: oklch(0.275 0.02 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
@@ -109,24 +110,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.68 0.186 265.215);
|
||||
--ring: oklch(0.645 0.215 16.439);
|
||||
--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.68 0.186 265.215);
|
||||
--chart-1: oklch(0.645 0.215 16.439);
|
||||
--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.68 0.186 265.215);
|
||||
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||
--sidebar-primary-foreground: oklch(0.95 0 0);
|
||||
--sidebar-accent: oklch(0.28 0.04 264.901);
|
||||
--sidebar-accent: oklch(0.28 0.06 16.439);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(0.25 0.01 0);
|
||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
||||
--sidebar-ring: oklch(0.645 0.215 16.439);
|
||||
}
|
||||
|
||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
||||
@@ -141,10 +142,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.55 0.2 275);
|
||||
--primary: oklch(0.586 0.222 17.585);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.93 0.02 275);
|
||||
--secondary-foreground: oklch(0.32 0.15 275);
|
||||
--secondary: oklch(0.955 0.02 17.585);
|
||||
--secondary-foreground: oklch(0.514 0.198 16.935);
|
||||
--muted: oklch(0.94 0.006 265);
|
||||
--muted-foreground: oklch(0.48 0.02 265);
|
||||
--accent: oklch(0.55 0.14 210);
|
||||
@@ -152,24 +153,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.55 0.2 275);
|
||||
--ring: oklch(0.586 0.222 17.585);
|
||||
--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.55 0.2 275);
|
||||
--chart-1: oklch(0.586 0.222 17.585);
|
||||
--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.64 0.19 275);
|
||||
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||
--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.64 0.19 275);
|
||||
--sidebar-ring: oklch(0.645 0.215 16.439);
|
||||
}
|
||||
|
||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||
|
||||
@@ -85,7 +85,8 @@ function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
|
||||
<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>
|
||||
<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">
|
||||
@@ -146,7 +147,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-foreground hover:text-foreground/70 transition-colors">
|
||||
<Link href="/login/forgot" className="text-sm font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
@@ -161,7 +162,7 @@ function LoginForm() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
className="w-full mt-8 h-12 text-base font-semibold rounded-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
|
||||
@@ -176,7 +177,7 @@ function LoginForm() {
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
|
||||
Don't have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
PieChart,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
Ruler,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
@@ -77,7 +76,15 @@ const navItems: {
|
||||
expandOnly?: boolean
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||
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[]
|
||||
}[]
|
||||
}[] = [
|
||||
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{
|
||||
@@ -89,8 +96,7 @@ 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", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -444,10 +450,17 @@ 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)))
|
||||
.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)))
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
children: bypassCodes.has(item.code)
|
||||
? item.children
|
||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
|
||||
@@ -112,10 +112,9 @@ 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 "Brands"
|
||||
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
|
||||
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-indigo-500 to-violet-600 text-white shadow-sm"
|
||||
? "bg-linear-to-br from-sidebar-primary to-primary text-primary-foreground shadow-sm"
|
||||
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
|
||||
@@ -8,9 +8,9 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-black text-white hover:bg-black/90 active:bg-black",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary",
|
||||
outline:
|
||||
"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",
|
||||
"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",
|
||||
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-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
|
||||
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"
|
||||
>
|
||||
{item.label}
|
||||
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
</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-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",
|
||||
"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",
|
||||
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-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",
|
||||
"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",
|
||||
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-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",
|
||||
"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",
|
||||
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-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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"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 }
|
||||
@@ -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-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",
|
||||
"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",
|
||||
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-violet-600 dark:text-violet-300" />
|
||||
<CheckIcon className="pointer-events-none text-primary" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
|
||||
@@ -71,26 +71,26 @@ export function StatCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-5 text-primary" />
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||
<p className="text-lg 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-sm">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// 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 { PagedResponse } from "@/types/common"
|
||||
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||
import { CreateUomRequest, Uom } from "@/types/master-data"
|
||||
|
||||
export interface ListUomsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
sort?: string
|
||||
}
|
||||
|
||||
@@ -19,4 +20,9 @@ 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 } })
|
||||
},
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -61,8 +61,6 @@ 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[]
|
||||
}
|
||||
|
||||
@@ -98,7 +96,6 @@ export interface Grn {
|
||||
poId: number | null
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
branchId: number
|
||||
status: GrnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
@@ -119,7 +116,6 @@ export interface GrnSummary {
|
||||
poId: number | null
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
branchId: number
|
||||
status: GrnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
|
||||
@@ -184,6 +184,9 @@ export type UpdateVendorRequest = CreateVendorRequest
|
||||
export interface Uom {
|
||||
uomId: number
|
||||
name: string
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateUomRequest {
|
||||
|
||||