Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f10f233dc | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 38c7545413 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
@@ -30,6 +30,12 @@ yarn-error.log*
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||||
|
Testing/e2e/playwright-report/
|
||||||
|
Testing/e2e/test-results/
|
||||||
|
Testing/e2e/.auth/
|
||||||
|
Testing/e2e/blob-report/
|
||||||
|
|
||||||
# ── Migrations ─────────────────────────────────────────────────────────
|
# ── Migrations ─────────────────────────────────────────────────────────
|
||||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
# Reverted 2026-07-31: excluding new EF Core migrations while
|
||||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
||||||
|
|||||||
-6696
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class SyncCurrentModel : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations;
|
|
||||||
|
|
||||||
public partial class AddBundleSalesModule : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_templates",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
|
||||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table => table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId));
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sales",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
|
||||||
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
|
|
||||||
table.ForeignKey("FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_customers_CustomerId", x => x.CustomerId, "customers", "CustomerId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_users_CashierUserId", x => x.CashierUserId, "users", "UserId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_template_lines",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_lines",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
ParentLineId = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_bundle_sales_BundleSaleId", x => x.BundleSaleId, "bundle_sales", "BundleSaleId", onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_templates_TemplateCode", table: "bundle_sale_templates", column: "TemplateCode", unique: true);
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleNo", table: "bundle_sales", column: "BundleNo", unique: true);
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleSaleTemplateId", table: "bundle_sales", column: "BundleSaleTemplateId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CashierUserId", table: "bundle_sales", column: "CashierUserId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CustomerId", table: "bundle_sales", column: "CustomerId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_Status", table: "bundle_sales", column: "Status");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_WarehouseId", table: "bundle_sales", column: "WarehouseId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_BundleSaleTemplateId", table: "bundle_sale_template_lines", column: "BundleSaleTemplateId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_ItemId", table: "bundle_sale_template_lines", column: "ItemId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_UomId", table: "bundle_sale_template_lines", column: "UomId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_WarehouseId", table: "bundle_sale_template_lines", column: "WarehouseId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_BundleSaleId", table: "bundle_sale_lines", column: "BundleSaleId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_ItemId", table: "bundle_sale_lines", column: "ItemId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_UomId", table: "bundle_sale_lines", column: "UomId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_WarehouseId", table: "bundle_sale_lines", column: "WarehouseId");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable("bundle_sale_lines");
|
|
||||||
migrationBuilder.DropTable("bundle_sale_template_lines");
|
|
||||||
migrationBuilder.DropTable("bundle_sales");
|
|
||||||
migrationBuilder.DropTable("bundle_sale_templates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations;
|
|
||||||
|
|
||||||
public partial class AddBundleSalesConcurrencyStamp : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sale_templates",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sales",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sales");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sale_templates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+400
-3
@@ -9,11 +9,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations
|
namespace ERPCore.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ErpDbContext))]
|
[DbContext(typeof(ErpDbContext))]
|
||||||
[Migration("20260801025920_InitialCreate")]
|
[Migration("20260804111315_a")]
|
||||||
partial class InitialCreate
|
partial class a
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -368,6 +368,269 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("brands", (string)null);
|
b.ToTable("brands", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
|
||||||
|
|
||||||
|
b.Property<string>("BundleCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("BundleDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("BundleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("BundleNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BundlePrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("CashierUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("ComponentSubtotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerSnapshotName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("GrandTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("MarginAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("CashierUserId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sales", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IncludeInBundle")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsComponent")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleLineId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("UomId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
|
||||||
|
|
||||||
|
b.Property<int>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("TemplateCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_templates", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleTemplateLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IncludeInBundle")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleTemplateLineId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("UomId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_template_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
@@ -1953,6 +2216,15 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
Label = "Accounts",
|
Label = "Accounts",
|
||||||
SortOrder = 12,
|
SortOrder = 12,
|
||||||
Status = "Active"
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 13,
|
||||||
|
Code = "sales",
|
||||||
|
Href = "/dashboard/sales",
|
||||||
|
Label = "Sales",
|
||||||
|
SortOrder = 13,
|
||||||
|
Status = "Active"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4540,6 +4812,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 23,
|
||||||
|
Code = "sales.bundle-sales",
|
||||||
|
Href = "/dashboard/sales/bundles",
|
||||||
|
Label = "Bundle Sales",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 17,
|
SubNavItemId = 17,
|
||||||
Code = "procurement.requisitions",
|
Code = "procurement.requisitions",
|
||||||
@@ -5152,6 +5434,111 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BundleSaleTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CashierUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSaleTemplate");
|
||||||
|
|
||||||
|
b.Navigation("CashierUser");
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("BundleSaleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSale");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("Uom");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("BundleSaleTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSaleTemplate");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("Uom");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||||
@@ -6558,6 +6945,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Quotation");
|
b.Navigation("Quotation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("SubCategories");
|
b.Navigation("SubCategories");
|
||||||
+255
-4
@@ -6,10 +6,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
|
|
||||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations
|
namespace ERPCore.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class InitialCreate : Migration
|
public partial class a : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
@@ -31,6 +31,25 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
table.PrimaryKey("PK_brands", x => x.BrandId);
|
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_templates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "categories",
|
name: "categories",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -903,6 +922,61 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sales",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
||||||
|
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId",
|
||||||
|
column: x => x.BundleSaleTemplateId,
|
||||||
|
principalTable: "bundle_sale_templates",
|
||||||
|
principalColumn: "BundleSaleTemplateId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sales_customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "customers",
|
||||||
|
principalColumn: "CustomerId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sales_users_CashierUserId",
|
||||||
|
column: x => x.CashierUserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sales_warehouses_WarehouseId",
|
||||||
|
column: x => x.WarehouseId,
|
||||||
|
principalTable: "warehouses",
|
||||||
|
principalColumn: "WarehouseId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "sales_invoices",
|
name: "sales_invoices",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -1020,6 +1094,50 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_template_lines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_template_lines_bundle_sale_templates_BundleSale~",
|
||||||
|
column: x => x.BundleSaleTemplateId,
|
||||||
|
principalTable: "bundle_sale_templates",
|
||||||
|
principalColumn: "BundleSaleTemplateId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_template_lines_items_ItemId",
|
||||||
|
column: x => x.ItemId,
|
||||||
|
principalTable: "items",
|
||||||
|
principalColumn: "ItemId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_template_lines_uoms_UomId",
|
||||||
|
column: x => x.UomId,
|
||||||
|
principalTable: "uoms",
|
||||||
|
principalColumn: "UomId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_template_lines_warehouses_WarehouseId",
|
||||||
|
column: x => x.WarehouseId,
|
||||||
|
principalTable: "warehouses",
|
||||||
|
principalColumn: "WarehouseId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "item_reorders",
|
name: "item_reorders",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -1332,6 +1450,53 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_lines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
ParentLineId = table.Column<int>(type: "integer", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_lines_bundle_sales_BundleSaleId",
|
||||||
|
column: x => x.BundleSaleId,
|
||||||
|
principalTable: "bundle_sales",
|
||||||
|
principalColumn: "BundleSaleId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_lines_items_ItemId",
|
||||||
|
column: x => x.ItemId,
|
||||||
|
principalTable: "items",
|
||||||
|
principalColumn: "ItemId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_lines_uoms_UomId",
|
||||||
|
column: x => x.UomId,
|
||||||
|
principalTable: "uoms",
|
||||||
|
principalColumn: "UomId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bundle_sale_lines_warehouses_WarehouseId",
|
||||||
|
column: x => x.WarehouseId,
|
||||||
|
principalTable: "warehouses",
|
||||||
|
principalColumn: "WarehouseId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "sales_invoice_lines",
|
name: "sales_invoice_lines",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -2782,7 +2947,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
||||||
{ 10, "help", "/dashboard/help", null, "Help", 10 },
|
{ 10, "help", "/dashboard/help", null, "Help", 10 },
|
||||||
{ 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 },
|
{ 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 },
|
||||||
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 }
|
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 },
|
||||||
|
{ 13, "sales", "/dashboard/sales", null, "Sales", 13 }
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
migrationBuilder.InsertData(
|
||||||
@@ -2835,7 +3001,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
|
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
|
||||||
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 },
|
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 },
|
||||||
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
|
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
|
||||||
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
|
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 },
|
||||||
|
{ 23, "sales.bundle-sales", "/dashboard/sales/bundles", null, "Bundle Sales", 13, 1 }
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
migrationBuilder.InsertData(
|
||||||
@@ -2905,6 +3072,78 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
table: "brands",
|
table: "brands",
|
||||||
column: "Status");
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_lines_BundleSaleId",
|
||||||
|
table: "bundle_sale_lines",
|
||||||
|
column: "BundleSaleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_lines_ItemId",
|
||||||
|
table: "bundle_sale_lines",
|
||||||
|
column: "ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_lines_UomId",
|
||||||
|
table: "bundle_sale_lines",
|
||||||
|
column: "UomId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_lines_WarehouseId",
|
||||||
|
table: "bundle_sale_lines",
|
||||||
|
column: "WarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_template_lines_BundleSaleTemplateId",
|
||||||
|
table: "bundle_sale_template_lines",
|
||||||
|
column: "BundleSaleTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_template_lines_ItemId",
|
||||||
|
table: "bundle_sale_template_lines",
|
||||||
|
column: "ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_template_lines_UomId",
|
||||||
|
table: "bundle_sale_template_lines",
|
||||||
|
column: "UomId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_template_lines_WarehouseId",
|
||||||
|
table: "bundle_sale_template_lines",
|
||||||
|
column: "WarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sale_templates_TemplateCode",
|
||||||
|
table: "bundle_sale_templates",
|
||||||
|
column: "TemplateCode",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sales_BundleNo",
|
||||||
|
table: "bundle_sales",
|
||||||
|
column: "BundleNo",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sales_BundleSaleTemplateId",
|
||||||
|
table: "bundle_sales",
|
||||||
|
column: "BundleSaleTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sales_CashierUserId",
|
||||||
|
table: "bundle_sales",
|
||||||
|
column: "CashierUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sales_CustomerId",
|
||||||
|
table: "bundle_sales",
|
||||||
|
column: "CustomerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bundle_sales_WarehouseId",
|
||||||
|
table: "bundle_sales",
|
||||||
|
column: "WarehouseId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_categories_Name",
|
name: "IX_categories_Name",
|
||||||
table: "categories",
|
table: "categories",
|
||||||
@@ -4234,6 +4473,12 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "audit_logs");
|
name: "audit_logs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "bundle_sale_lines");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "bundle_sale_template_lines");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "hr_attendance_records");
|
name: "hr_attendance_records");
|
||||||
|
|
||||||
@@ -4336,6 +4581,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "vendor_quotation_lines");
|
name: "vendor_quotation_lines");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "bundle_sales");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "hr_attendance_upload_batches");
|
name: "hr_attendance_upload_batches");
|
||||||
|
|
||||||
@@ -4393,6 +4641,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "vendor_quotations");
|
name: "vendor_quotations");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "bundle_sale_templates");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "hr_payroll_runs");
|
name: "hr_payroll_runs");
|
||||||
|
|
||||||
+399
-2
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations
|
namespace ERPCore.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ErpDbContext))]
|
[DbContext(typeof(ErpDbContext))]
|
||||||
partial class ErpDbContextModelSnapshot : ModelSnapshot
|
partial class ErpDbContextModelSnapshot : ModelSnapshot
|
||||||
@@ -17,7 +17,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.9")
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
@@ -365,6 +365,269 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("brands", (string)null);
|
b.ToTable("brands", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
|
||||||
|
|
||||||
|
b.Property<string>("BundleCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("BundleDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("BundleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("BundleNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BundlePrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("CashierUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("ComponentSubtotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerSnapshotName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("GrandTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("MarginAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("CashierUserId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sales", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IncludeInBundle")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsComponent")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleLineId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("UomId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
|
||||||
|
|
||||||
|
b.Property<int>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("TemplateCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_templates", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BundleSaleTemplateLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IncludeInBundle")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("BundleSaleTemplateLineId");
|
||||||
|
|
||||||
|
b.HasIndex("BundleSaleTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("UomId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("bundle_sale_template_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
@@ -1950,6 +2213,15 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
Label = "Accounts",
|
Label = "Accounts",
|
||||||
SortOrder = 12,
|
SortOrder = 12,
|
||||||
Status = "Active"
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 13,
|
||||||
|
Code = "sales",
|
||||||
|
Href = "/dashboard/sales",
|
||||||
|
Label = "Sales",
|
||||||
|
SortOrder = 13,
|
||||||
|
Status = "Active"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4537,6 +4809,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 23,
|
||||||
|
Code = "sales.bundle-sales",
|
||||||
|
Href = "/dashboard/sales/bundles",
|
||||||
|
Label = "Bundle Sales",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 17,
|
SubNavItemId = 17,
|
||||||
Code = "procurement.requisitions",
|
Code = "procurement.requisitions",
|
||||||
@@ -5149,6 +5431,111 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BundleSaleTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CashierUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSaleTemplate");
|
||||||
|
|
||||||
|
b.Navigation("CashierUser");
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("BundleSaleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSale");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("Uom");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("BundleSaleTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BundleSaleTemplate");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("Uom");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||||
@@ -6555,6 +6942,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Quotation");
|
b.Navigation("Quotation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("SubCategories");
|
b.Navigation("SubCategories");
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
|
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:5011"
|
"BaseUrl": "http://localhost:5011"
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "ERPCore",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -129,6 +129,8 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
|||||||
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
||||||
>
|
>
|
||||||
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
||||||
|
>
|
||||||
|
> **2026-08-05 — Cheque Management status/type fields were rendering as raw integers, not names (user-reported + confirmed with GL's own `06_Enums_Reference.md`).** That doc's key fact: GL has no global `JsonStringEnumConverter`. A JSON-**body** enum field (e.g. the Issue-cheque form's `payeeType`) is independently declared `string` server-side and parsed via `Enum.TryParse`, and a query-string enum filter binds natively by name — both already correct here, unaffected. But `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are genuine enum-typed properties on GL's own **response** DTOs, backed by real `integer` DB columns — with no converter, GL's JSON serializes each one as its raw number (`1`/`2`/`3`/...), not its name. This wasn't just a cosmetic label bug: every list badge, the dialogs' status-based available-actions logic, and any `===` comparison against this frontend's own string enums (`ChequeBookStatus.Active`, etc.) would have silently mismatched against these numbers. Fixed at the API boundary, not scattered across every consumer: added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (one per affected field, keyed by the exact integers `06_Enums_Reference.md` documents), and applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing GL's actual `number`/`number | null` response shape for these fields) plus `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one — so every page/dialog/badge map keeps working against the same string values as before, unchanged. Cross-checked every other enum in that doc's "Persisted enums" table against this frontend (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) — none are consumed anywhere in this app, confirming Cheque Management was the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||||
|
|
||||||
## 7. UX states
|
## 7. UX states
|
||||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
|
|||||||
emergencyContactName: employee.emergencyContactName,
|
emergencyContactName: employee.emergencyContactName,
|
||||||
emergencyContactRelationship: employee.emergencyContactRelationship,
|
emergencyContactRelationship: employee.emergencyContactRelationship,
|
||||||
emergencyContactPhone: employee.emergencyContactPhone,
|
emergencyContactPhone: employee.emergencyContactPhone,
|
||||||
|
hireDate: employee.hireDate,
|
||||||
confirmationDate: employee.confirmationDate,
|
confirmationDate: employee.confirmationDate,
|
||||||
lastWorkingDate: employee.lastWorkingDate,
|
lastWorkingDate: employee.lastWorkingDate,
|
||||||
departmentId: employee.departmentId,
|
departmentId: employee.departmentId,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -195,10 +194,10 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||||
setPo(updated)
|
setPo(updated)
|
||||||
setLines(toDraftLines(updated))
|
setLines(toDraftLines(updated))
|
||||||
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveError(errorMessage(err))
|
setSaveError(errorMessage(err))
|
||||||
toast.error("Could not submit purchase order", errorMessage(err))
|
toast.error("Could not approve purchase order", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -283,9 +282,9 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{po.status === "Draft" && (
|
{po.status === "Draft" && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||||
<Send className="size-5" />
|
<Check className="size-5" />
|
||||||
{submitting ? "Submitting…" : "Submit"}
|
{submitting ? "Approving…" : "Approve"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||||
@@ -15,13 +15,22 @@ import { uomsApi } from "@/lib/api/uoms"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validatePoLine } from "@/lib/validations/procurement"
|
import { validatePoLine } from "@/lib/validations/procurement"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
import { CreatePoLineInput } from "@/types/procurement"
|
import { CreatePoLineInput } from "@/types/procurement"
|
||||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } 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 { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -43,10 +52,10 @@ function newKey() {
|
|||||||
return `poline-${keySeq}`
|
return `poline-${keySeq}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
// there) and stays off this form, though it remains on the payload since the backend line
|
||||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||||
// from an RFQ keeps its negotiated price (below).
|
// from its negotiated price (below) but stays editable.
|
||||||
function emptyLine(): DraftLine {
|
function emptyLine(): DraftLine {
|
||||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||||
}
|
}
|
||||||
@@ -73,22 +82,83 @@ function NewPurchaseOrderContent() {
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||||
|
const [vName, setVName] = useState("")
|
||||||
|
const [vTerms, setVTerms] = useState("")
|
||||||
|
const [vTaxReg, setVTaxReg] = useState("")
|
||||||
|
const [vCurrency, setVCurrency] = useState("LKR")
|
||||||
|
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||||
|
const [vSubmitting, setVSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
|
||||||
|
|
||||||
|
function loadItems() {
|
||||||
|
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
|
||||||
|
}
|
||||||
|
function loadVendors() {
|
||||||
|
return vendorsApi.list({ pageSize: 200, status: "Active" }).then((ve) => setVendors(ve.items))
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
loadItems(),
|
||||||
uomsApi.list(),
|
uomsApi.list().then((uo) => setUoms(uo.items)),
|
||||||
warehousesApi.list(),
|
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
loadVendors(),
|
||||||
])
|
]).catch((err) => setLoadError(errorMessage(err)))
|
||||||
.then(([it, uo, wh, ve]) => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
setItems(it.items)
|
|
||||||
setUoms(uo.items)
|
|
||||||
setWarehouses(wh.items)
|
|
||||||
setVendors(ve.items)
|
|
||||||
})
|
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// A new item is created on the standalone item builder (too many fields for a modal here),
|
||||||
|
// typically in another tab — refetch on refocus so it shows up in the line pickers without
|
||||||
|
// the user having to reload this page and lose their draft.
|
||||||
|
useEffect(() => {
|
||||||
|
function onFocus() {
|
||||||
|
loadItems().catch(() => {})
|
||||||
|
}
|
||||||
|
window.addEventListener("focus", onFocus)
|
||||||
|
return () => window.removeEventListener("focus", onFocus)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function resetVendorForm() {
|
||||||
|
setVName("")
|
||||||
|
setVTerms("")
|
||||||
|
setVTaxReg("")
|
||||||
|
setVCurrency("LKR")
|
||||||
|
setVErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateVendor() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||||
|
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||||
|
setVErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setVSubmitting(true)
|
||||||
|
try {
|
||||||
|
const result = await vendorsApi.create({
|
||||||
|
code: generatedVendorCode,
|
||||||
|
name: vName,
|
||||||
|
terms: vTerms || null,
|
||||||
|
taxReg: vTaxReg || null,
|
||||||
|
currency: vCurrency,
|
||||||
|
})
|
||||||
|
await loadVendors()
|
||||||
|
setVendorId(result.data.vendorId)
|
||||||
|
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||||
|
setVendorDialogOpen(false)
|
||||||
|
resetVendorForm()
|
||||||
|
} catch (err) {
|
||||||
|
// A 409 here means another creation raced ours for the same generated code — the
|
||||||
|
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
|
||||||
|
toast.error("Could not create vendor", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setVSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requisitionId) {
|
if (requisitionId) {
|
||||||
requisitionsApi
|
requisitionsApi
|
||||||
@@ -236,43 +306,72 @@ function NewPurchaseOrderContent() {
|
|||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2 sm:col-span-1">
|
||||||
<Label className="text-base">Vendor code</Label>
|
<Label className="text-base">Vendor</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={vendorId}
|
value={vendorId}
|
||||||
onValueChange={setVendorId}
|
onValueChange={setVendorId}
|
||||||
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
items={(vendors ?? []).map((v) => ({ label: `${v.code} — ${v.name}`, value: v.vendorId }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="Select vendor code" />
|
<SelectValue placeholder="Select vendor" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(vendors ?? []).map((v) => (
|
{(vendors ?? []).map((v) => (
|
||||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||||
{v.code}
|
{v.code} — {v.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>New vendor</DialogTitle>
|
||||||
|
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!vErrors.name}>
|
||||||
|
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||||
|
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
|
||||||
|
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||||
|
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||||
|
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||||
|
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!vErrors.currency}>
|
||||||
|
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||||
|
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||||
|
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : 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={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
|
||||||
|
{vSubmitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Vendor name</Label>
|
|
||||||
<Select<number | null>
|
|
||||||
value={vendorId}
|
|
||||||
onValueChange={setVendorId}
|
|
||||||
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue placeholder="Select vendor name" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(vendors ?? []).map((v) => (
|
|
||||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
|
||||||
{v.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
{requisitionId && (
|
{requisitionId && (
|
||||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||||
@@ -287,21 +386,34 @@ function NewPurchaseOrderContent() {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/products/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }))}
|
||||||
|
title="Opens in a new tab — the item list here refreshes when you come back"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-5" />
|
||||||
|
New item
|
||||||
|
</Link>
|
||||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
Add line
|
Add line
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{lines.length > 0 && (
|
{lines.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="text-base">
|
<Table className="table-fixed text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -313,11 +425,15 @@ function NewPurchaseOrderContent() {
|
|||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{requisitionId || rfqId ? (
|
{requisitionId || rfqId ? (
|
||||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
<SelectTrigger
|
||||||
|
className="h-11! w-full text-base"
|
||||||
|
aria-invalid={!!errors.itemId}
|
||||||
|
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||||
|
>
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -374,6 +490,18 @@ function NewPurchaseOrderContent() {
|
|||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={line.unitPrice}
|
||||||
|
aria-invalid={!!errors.unitPrice}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
|
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||||
|
|
||||||
type StatusFilter = PurchaseOrderStatus | "All"
|
type StatusFilter = PurchaseOrderStatus | "All"
|
||||||
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [approvingId, setApprovingId] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
|
|||||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(po: PurchaseOrderSummary) {
|
||||||
|
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||||
|
setDeletingId(po.poId)
|
||||||
|
try {
|
||||||
|
await purchaseOrdersApi.remove(po.poId)
|
||||||
|
toast.success("Draft deleted", po.docNo)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete purchase order", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove(po: PurchaseOrderSummary) {
|
||||||
|
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
||||||
|
setApprovingId(po.poId)
|
||||||
|
try {
|
||||||
|
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||||
|
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not approve purchase order", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setApprovingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,10 +164,13 @@ export default function PurchaseOrdersListPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
|
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{pos.map((po) => (
|
{pos.map((po) => {
|
||||||
|
const editable = isPoEditable(po.status)
|
||||||
|
return (
|
||||||
<TableRow key={po.poId}>
|
<TableRow key={po.poId}>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||||
@@ -149,8 +183,57 @@ export default function PurchaseOrdersListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
aria-label="View"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
aria-label="Edit draft"
|
||||||
|
title="Edit draft"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Approve draft"
|
||||||
|
title="Approve draft"
|
||||||
|
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||||
|
onClick={() => handleApprove(po)}
|
||||||
|
className="text-success hover:bg-success/10 hover:text-success"
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Delete draft"
|
||||||
|
title="Delete draft"
|
||||||
|
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||||
|
onClick={() => handleDelete(po)}
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Plus, Trash2, X } from "lucide-react"
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Field, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
@@ -175,13 +176,11 @@ export function StageEditorPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
|
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||||
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
|
<DialogHeader>
|
||||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
|
<DialogTitle>Stage editor</DialogTitle>
|
||||||
<X className="size-5" />
|
</DialogHeader>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
@@ -434,6 +433,7 @@ export function StageEditorPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
|
|||||||
return new Set(named.map((n) => n.id))
|
return new Set(named.map((n) => n.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TemplateBuilderPage() {
|
function TemplateBuilderContent() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -765,8 +765,7 @@ export default function TemplateBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={displayNodes}
|
nodes={displayNodes}
|
||||||
@@ -805,6 +804,13 @@ export default function TemplateBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateBuilderPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
|
||||||
|
<TemplateBuilderContent />
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export default function ItemDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
|
||||||
>
|
>
|
||||||
{item.status}
|
{item.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -257,7 +257,15 @@ export default function BrandsPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
<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 font-medium">{b.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
|
<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>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
|||||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Category, SubCategory } from "@/types/master-data"
|
import { Category, SubCategory } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function ItemsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.status}
|
{item.status}
|
||||||
|
|||||||
@@ -443,10 +443,10 @@ export default function NewGrnPage() {
|
|||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||||
@@ -653,7 +653,7 @@ export default function NewGrnPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!poLoading && lines.length > 0 && (
|
{!poLoading && lines.length > 0 && (
|
||||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
||||||
@@ -16,6 +16,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -28,7 +29,7 @@ type EditableLine = BundleSaleTemplateLine & { key: string }
|
|||||||
|
|
||||||
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||||
|
|
||||||
export default function NewBundleSalePage() {
|
function NewBundleSaleContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const templateFromQuery = searchParams.get("templateId")
|
const templateFromQuery = searchParams.get("templateId")
|
||||||
@@ -286,3 +287,11 @@ export default function NewBundleSalePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function NewBundleSalePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||||
|
<NewBundleSaleContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -309,7 +309,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||||
<div className="text-sm text-muted-foreground">Location: {warehouse?.location ?? "—"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function formatHeader(key: string) {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCell(value: unknown) {
|
function formatCell(value: unknown): string {
|
||||||
if (value === null || value === undefined) return ""
|
if (value === null || value === undefined) return ""
|
||||||
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
|
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|||||||
@@ -1,135 +1,22 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Building2, Save } from "lucide-react"
|
import { Building2 } from "lucide-react"
|
||||||
|
|
||||||
import { companyApi } from "@/lib/api/company"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CompanyProfile } from "@/types/company"
|
|
||||||
|
|
||||||
import { buttonVariants, Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
export default function CompanyProfilePage() {
|
export default function CompanyProfilePage() {
|
||||||
const [profile, setProfile] = useState<CompanyProfile | null>(null)
|
|
||||||
const [etag, setEtag] = useState<string | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
companyApi
|
|
||||||
.getProfile()
|
|
||||||
.then((res) => {
|
|
||||||
setProfile(res.data)
|
|
||||||
setEtag(res.etag)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function patch<K extends keyof CompanyProfile>(key: K, value: CompanyProfile[K]) {
|
|
||||||
setProfile((prev) => (prev ? { ...prev, [key]: value } : prev))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!profile || !etag) return
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const updated = await companyApi.updateProfile(profile, etag)
|
|
||||||
setProfile(updated.data)
|
|
||||||
setEtag(updated.etag)
|
|
||||||
toast.success("Company profile saved", updated.data.legalName)
|
|
||||||
} catch (err) {
|
|
||||||
setError(errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
|
||||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
<Building2 className="size-10 text-muted-foreground" />
|
||||||
<ArrowLeft className="size-5" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
|
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
|
||||||
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
|
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
|
||||||
{!error && !profile && <Skeleton className="h-64 w-full" />}
|
|
||||||
|
|
||||||
{!error && profile && (
|
|
||||||
<div className="flex flex-col gap-6 rounded-2xl border p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Building2 className="size-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold">Invoice Header</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field label="Legal Name" value={profile.legalName} onChange={(v) => patch("legalName", v)} />
|
|
||||||
<Field label="Trade Name" value={profile.tradeName ?? ""} onChange={(v) => patch("tradeName", v)} />
|
|
||||||
<Field label="Logo URL" value={profile.logoUrl ?? ""} onChange={(v) => patch("logoUrl", v)} />
|
|
||||||
<Field label="Tax Registration No" value={profile.taxRegistrationNo ?? ""} onChange={(v) => patch("taxRegistrationNo", v)} />
|
|
||||||
<Field label="VAT Registration No" value={profile.vatRegistrationNo ?? ""} onChange={(v) => patch("vatRegistrationNo", v)} />
|
|
||||||
<Field label="Phone" value={profile.phone ?? ""} onChange={(v) => patch("phone", v)} />
|
|
||||||
<Field label="Email" value={profile.email ?? ""} onChange={(v) => patch("email", v)} />
|
|
||||||
<Field label="City" value={profile.city ?? ""} onChange={(v) => patch("city", v)} />
|
|
||||||
<Field label="Country" value={profile.country ?? ""} onChange={(v) => patch("country", v)} />
|
|
||||||
<Field label="Address Line 1" value={profile.addressLine1 ?? ""} onChange={(v) => patch("addressLine1", v)} />
|
|
||||||
<Field label="Address Line 2" value={profile.addressLine2 ?? ""} onChange={(v) => patch("addressLine2", v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Save className="size-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold">Bank Details</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Field label="Bank Name" value={profile.bankName ?? ""} onChange={(v) => patch("bankName", v)} />
|
|
||||||
<Field label="Bank Branch" value={profile.bankBranch ?? ""} onChange={(v) => patch("bankBranch", v)} />
|
|
||||||
<Field label="Account Name" value={profile.accountName ?? ""} onChange={(v) => patch("accountName", v)} />
|
|
||||||
<Field label="Account Number" value={profile.accountNumber ?? ""} onChange={(v) => patch("accountNumber", v)} />
|
|
||||||
<Field label="SWIFT Code" value={profile.swiftCode ?? ""} onChange={(v) => patch("swiftCode", v)} />
|
|
||||||
<Field label="Footer Note" value={profile.footerNote ?? ""} onChange={(v) => patch("footerNote", v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Back to Settings
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" onClick={save} disabled={saving}>
|
|
||||||
{saving ? "Saving..." : "Save Profile"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>{label}</Label>
|
|
||||||
<Input value={value} onChange={(e) => onChange(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,3 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import {
|
|
||||||
AlertOctagon,
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeftRight,
|
|
||||||
BadgeDollarSign,
|
|
||||||
ClipboardList,
|
|
||||||
PackageSearch,
|
|
||||||
ScrollText,
|
|
||||||
SlidersHorizontal,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
|
|
||||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
|
||||||
{
|
|
||||||
title: "Stock Enquiry",
|
|
||||||
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
|
|
||||||
href: "/dashboard/stock/enquiry",
|
|
||||||
icon: PackageSearch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Stock Ledger",
|
|
||||||
description: "The immutable, append-only movement journal — every in/out with running balance.",
|
|
||||||
href: "/dashboard/stock/ledger",
|
|
||||||
icon: ScrollText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Valuation",
|
|
||||||
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
|
|
||||||
href: "/dashboard/stock/valuation",
|
|
||||||
icon: BadgeDollarSign,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Transfers",
|
|
||||||
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
|
|
||||||
href: "/dashboard/stock/transfers",
|
|
||||||
icon: ArrowLeftRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Adjustments",
|
|
||||||
description: "Increase, decrease, or write off stock with a mandatory reason code.",
|
|
||||||
href: "/dashboard/stock/adjustments",
|
|
||||||
icon: SlidersHorizontal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Counts",
|
|
||||||
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
|
|
||||||
href: "/dashboard/stock/counts",
|
|
||||||
icon: ClipboardList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Reorder Alerts",
|
|
||||||
description: "Items at or below their reorder point, with a one-click requisition.",
|
|
||||||
href: "/dashboard/stock/reorder-alerts",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Wastage",
|
|
||||||
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
|
|
||||||
href: "/dashboard/stock/wastage",
|
|
||||||
icon: AlertOctagon,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function StockHubPage() {
|
export default function StockHubPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -73,26 +7,6 @@ export default function StockHubPage() {
|
|||||||
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{areas.map((area) => (
|
|
||||||
<Link key={area.href} href={area.href}>
|
|
||||||
<Card className="h-full transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
||||||
<area.icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -5,8 +5,9 @@ import Link from "next/link"
|
|||||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||||
|
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Vendor } from "@/types/master-data"
|
import { Vendor } from "@/types/master-data"
|
||||||
|
|
||||||
@@ -44,7 +45,6 @@ export default function VendorsPage() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [terms, setTerms] = useState("")
|
const [terms, setTerms] = useState("")
|
||||||
const [taxReg, setTaxReg] = useState("")
|
const [taxReg, setTaxReg] = useState("")
|
||||||
@@ -52,8 +52,14 @@ export default function VendorsPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Separate from the paginated table list above — this needs every existing code (up to the
|
||||||
|
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
|
||||||
|
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
|
||||||
|
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
@@ -75,8 +81,11 @@ export default function VendorsPage() {
|
|||||||
|
|
||||||
useEffect(load, [query, status, page])
|
useEffect(load, [query, status, page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setCode("")
|
|
||||||
setName("")
|
setName("")
|
||||||
setTerms("")
|
setTerms("")
|
||||||
setTaxReg("")
|
setTaxReg("")
|
||||||
@@ -86,7 +95,6 @@ export default function VendorsPage() {
|
|||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
|
||||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
@@ -94,14 +102,15 @@ export default function VendorsPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency })
|
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
load()
|
load()
|
||||||
|
setAllVendorCodes((codes) => [...codes, result.data.code])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fe = fieldErrors(err)
|
// A 409 here means another creation raced ours for the same generated code — the
|
||||||
if (fe?.code) setErrors({ code: fe.code })
|
// proactive de-dupe above only knows about codes loaded when the dialog opened.
|
||||||
toast.error("Could not create vendor", errorMessage(err))
|
toast.error("Could not create vendor", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -145,19 +154,18 @@ export default function VendorsPage() {
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New vendor</DialogTitle>
|
<DialogTitle>New vendor</DialogTitle>
|
||||||
<DialogDescription>Create a supplier record.</DialogDescription>
|
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.code}>
|
|
||||||
<FieldLabel htmlFor="v-code">Code</FieldLabel>
|
|
||||||
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
|
|
||||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
||||||
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
|
||||||
|
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Printer } from "lucide-react"
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { bundleApi } from "@/lib/api/bundles"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { BundleSaleSummary } from "@/types/bundles"
|
import { BundleSaleSummary } from "@/types/bundles"
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function statusClass(status: BundleSaleSummary["status"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BundleBatchPrintPage() {
|
function BundleBatchPrintContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const status = searchParams.get("status") ?? "All"
|
const status = searchParams.get("status") ?? "All"
|
||||||
const query = searchParams.get("q") ?? ""
|
const query = searchParams.get("q") ?? ""
|
||||||
@@ -88,3 +89,11 @@ export default function BundleBatchPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function BundleBatchPrintPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||||
|
<BundleBatchPrintContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Printer } from "lucide-react"
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { SalesInvoiceSummary } from "@/types/sales"
|
import { SalesInvoiceSummary } from "@/types/sales"
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function statusClass(status: SalesInvoiceSummary["status"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SalesInvoiceBatchPrintPage() {
|
function SalesInvoiceBatchPrintContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const status = searchParams.get("status") ?? "All"
|
const status = searchParams.get("status") ?? "All"
|
||||||
const query = searchParams.get("q") ?? ""
|
const query = searchParams.get("q") ?? ""
|
||||||
@@ -105,3 +106,11 @@ export default function SalesInvoiceBatchPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function SalesInvoiceBatchPrintPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||||
|
<SalesInvoiceBatchPrintContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Printer } from "lucide-react"
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { SalesSlipSummary } from "@/types/sales"
|
import { SalesSlipSummary } from "@/types/sales"
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function statusClass(status: SalesSlipSummary["status"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SalesSlipBatchPrintPage() {
|
function SalesSlipBatchPrintContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const status = searchParams.get("status") ?? "All"
|
const status = searchParams.get("status") ?? "All"
|
||||||
const query = searchParams.get("q") ?? ""
|
const query = searchParams.get("q") ?? ""
|
||||||
@@ -103,3 +104,11 @@ export default function SalesSlipBatchPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function SalesSlipBatchPrintPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||||
|
<SalesSlipBatchPrintContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
|
AlertOctagon,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeftRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
PackageSearch,
|
||||||
PackageX,
|
PackageX,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PieChart,
|
PieChart,
|
||||||
@@ -36,6 +40,7 @@ import {
|
|||||||
ReceiptText,
|
ReceiptText,
|
||||||
Ruler,
|
Ruler,
|
||||||
Scale,
|
Scale,
|
||||||
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
@@ -116,7 +121,26 @@ const navItems: {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
{
|
||||||
|
title: "Stock",
|
||||||
|
code: "stock",
|
||||||
|
href: "/dashboard/stock",
|
||||||
|
// Clicking "Stock" itself lands on Stock Ledger — the hub page underneath has nothing on
|
||||||
|
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||||
|
landingHref: "/dashboard/stock/ledger",
|
||||||
|
icon: Warehouse,
|
||||||
|
chevron: true,
|
||||||
|
children: [
|
||||||
|
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||||
|
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||||
|
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
||||||
|
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||||
|
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||||
|
{ title: "Counts", code: "stock.counts", href: "/dashboard/stock/counts", icon: ClipboardList },
|
||||||
|
{ title: "Reorder Alerts", code: "stock.reorder-alerts", href: "/dashboard/stock/reorder-alerts", icon: AlertTriangle },
|
||||||
|
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||||
{
|
{
|
||||||
@@ -402,14 +426,15 @@ export function AppSidebar() {
|
|||||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
// is visible if its own code is granted, or (for parents) if any child is.
|
// is visible if its own code is granted, or (for parents) if any child is.
|
||||||
//
|
//
|
||||||
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
|
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||||
|
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||||
// anything server-side.
|
// anything server-side.
|
||||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
|
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Plus } from "lucide-react"
|
|||||||
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -27,7 +27,7 @@ interface CodeNamed {
|
|||||||
|
|
||||||
interface Api<T extends CodeNamed> {
|
interface Api<T extends CodeNamed> {
|
||||||
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||||
create(request: { code: string; name: string }): Promise<{ value: T }>
|
create(request: { code: string; name: string }): Promise<ApiResult<T>>
|
||||||
updateStatus(id: number, status: EntityStatus): Promise<void>
|
updateStatus(id: number, status: EntityStatus): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
ReceivedFromType,
|
ReceivedFromType,
|
||||||
CreateReceivedChequeRequest,
|
CreateReceivedChequeRequest,
|
||||||
UpdateReceivedChequeStatusRequest,
|
UpdateReceivedChequeStatusRequest,
|
||||||
|
CHEQUE_BOOK_STATUS_BY_CODE,
|
||||||
|
CHEQUE_PAGE_ISSUE_STATUS_BY_CODE,
|
||||||
|
PAYEE_TYPE_BY_CODE,
|
||||||
|
RECEIVED_FROM_TYPE_BY_CODE,
|
||||||
|
RECEIVED_CHEQUE_STATUS_BY_CODE,
|
||||||
} from "@/types/general-ledger"
|
} from "@/types/general-ledger"
|
||||||
|
|
||||||
const GL_BASE = "/api/v1/gl"
|
const GL_BASE = "/api/v1/gl"
|
||||||
@@ -257,50 +262,100 @@ export const cashAccountTypesApi = {
|
|||||||
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
|
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
|
||||||
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
|
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
|
||||||
*/
|
*/
|
||||||
|
// GL sends `ChequeBook.status`/`ChequePage.issueStatus`/`ChequePage.payeeType`/
|
||||||
|
// `ReceivedCheque.receivedFromType`/`ReceivedCheque.status` as raw integers, not their string
|
||||||
|
// name (06_Enums_Reference.md — no global JsonStringEnumConverter on GL's side; see the long
|
||||||
|
// comment above the `*_BY_CODE` maps in types/general-ledger.ts for why). These `Raw*` shapes
|
||||||
|
// describe exactly what GL's JSON actually contains for those fields; the `map*` functions below
|
||||||
|
// translate them into this frontend's normal string-enum `ChequeBook`/`ChequePage`/`ReceivedCheque`
|
||||||
|
// types immediately after each call returns, so every other file in this app can keep comparing
|
||||||
|
// against `ChequeBookStatus.Active` etc. exactly as before.
|
||||||
|
type RawChequePage = Omit<ChequePage, "issueStatus" | "payeeType"> & {
|
||||||
|
issueStatus: number
|
||||||
|
payeeType: number | null
|
||||||
|
}
|
||||||
|
type RawChequeBook = Omit<ChequeBook, "status" | "pages"> & {
|
||||||
|
status: number
|
||||||
|
pages: RawChequePage[]
|
||||||
|
}
|
||||||
|
type RawReceivedCheque = Omit<ReceivedCheque, "receivedFromType" | "status"> & {
|
||||||
|
receivedFromType: number
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChequePage(raw: RawChequePage): ChequePage {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
issueStatus: CHEQUE_PAGE_ISSUE_STATUS_BY_CODE[raw.issueStatus],
|
||||||
|
payeeType: raw.payeeType == null ? null : PAYEE_TYPE_BY_CODE[raw.payeeType],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChequeBook(raw: RawChequeBook): ChequeBook {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
status: CHEQUE_BOOK_STATUS_BY_CODE[raw.status],
|
||||||
|
pages: (raw.pages ?? []).map(mapChequePage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReceivedCheque(raw: RawReceivedCheque): ReceivedCheque {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
receivedFromType: RECEIVED_FROM_TYPE_BY_CODE[raw.receivedFromType],
|
||||||
|
status: RECEIVED_CHEQUE_STATUS_BY_CODE[raw.status],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const chequeBooksApi = {
|
export const chequeBooksApi = {
|
||||||
list(params?: {
|
async list(params?: {
|
||||||
bankAccountId?: number
|
bankAccountId?: number
|
||||||
branchId?: number
|
branchId?: number
|
||||||
status?: ChequeBookStatus
|
status?: ChequeBookStatus
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}): Promise<GlPagedResult<ChequeBook>> {
|
}): Promise<GlPagedResult<ChequeBook>> {
|
||||||
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
|
const res = await glRequest<GlPagedResult<RawChequeBook>>("/cheque-books", { query: { ...params } })
|
||||||
|
return { ...res, items: res.items.map(mapChequeBook) }
|
||||||
},
|
},
|
||||||
|
|
||||||
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
|
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
|
||||||
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
async get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||||
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
const res = await glRequest<RawChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||||
query: expandPages ? { expand: "pages" } : undefined,
|
query: expandPages ? { expand: "pages" } : undefined,
|
||||||
})
|
})
|
||||||
|
return mapChequeBook(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
|
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
|
||||||
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
async create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||||
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
|
const res = await glRequest<RawChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||||
|
return mapChequeBook(res)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chequePagesApi = {
|
export const chequePagesApi = {
|
||||||
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
async issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: request,
|
body: request,
|
||||||
})
|
})
|
||||||
|
return mapChequePage(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
|
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
|
||||||
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
async updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: request,
|
body: request,
|
||||||
})
|
})
|
||||||
|
return mapChequePage(res)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
|
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
|
||||||
export const receivedChequesApi = {
|
export const receivedChequesApi = {
|
||||||
list(params?: {
|
async list(params?: {
|
||||||
companyId?: number
|
companyId?: number
|
||||||
branchId?: number
|
branchId?: number
|
||||||
status?: ReceivedChequeStatus
|
status?: ReceivedChequeStatus
|
||||||
@@ -308,15 +363,18 @@ export const receivedChequesApi = {
|
|||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}): Promise<GlPagedResult<ReceivedCheque>> {
|
}): Promise<GlPagedResult<ReceivedCheque>> {
|
||||||
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
|
const res = await glRequest<GlPagedResult<RawReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||||
|
return { ...res, items: res.items.map(mapReceivedCheque) }
|
||||||
},
|
},
|
||||||
|
|
||||||
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
async create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||||
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
const res = await glRequest<RawReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||||
|
return mapReceivedCheque(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
|
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
|
||||||
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
async updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||||
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
const res = await glRequest<RawReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||||
|
return mapReceivedCheque(res)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "VN"
|
||||||
|
* so an empty/punctuation-only name still yields a usable base. Mirrors the warehouse
|
||||||
|
* code generator (app/dashboard/warehouse/page.tsx). */
|
||||||
|
function vendorCodeBase(name: string): string {
|
||||||
|
const firstWord = name.trim().split(/\s+/)[0] ?? ""
|
||||||
|
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||||
|
return cleaned.slice(0, 10) || "VN"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
|
||||||
|
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
|
||||||
|
export function generateVendorCode(name: string, existingCodes: string[]): string {
|
||||||
|
const base = `VN-${vendorCodeBase(name)}`
|
||||||
|
if (!existingCodes.includes(base)) return base
|
||||||
|
let suffix = 2
|
||||||
|
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
|
||||||
|
return `${base}${suffix}`
|
||||||
|
}
|
||||||
@@ -410,6 +410,59 @@ export enum ReceivedChequeStatusAction {
|
|||||||
Cancel = "Cancel",
|
Cancel = "Cancel",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmed (`06_Enums_Reference.md`, user-supplied) — GL has **no global `JsonStringEnumConverter`**
|
||||||
|
* registered. That's documented there as a request-body binding gotcha (a JSON-body enum field must
|
||||||
|
* be sent as its string name, parsed server-side via `Enum.TryParse`), but the same missing converter
|
||||||
|
* also governs the other direction: every one of these five fields is a real enum-typed property on
|
||||||
|
* GL's own response DTO (backed by an `integer` DB column, per that doc's "Persisted enums" table),
|
||||||
|
* so with no converter registered, GL's JSON response serializes each one as its **raw integer**
|
||||||
|
* (`1`/`2`/`3`/...), not the name — confirmed live by the user ("most status has integer numbers").
|
||||||
|
* Request bodies/query-string filters are unaffected and still take the string name as before (a
|
||||||
|
* JSON-body enum field is independently declared `string` server-side, and query-string enum
|
||||||
|
* binding parses names natively) — only inbound response values need translating. These maps do
|
||||||
|
* that translation, keyed by the exact integer values `06_Enums_Reference.md` documents; applied in
|
||||||
|
* `lib/api/general-ledger.ts` immediately after each GL call returns, so every consumer of
|
||||||
|
* `ChequeBook`/`ChequePage`/`ReceivedCheque` in this frontend keeps working with the same string
|
||||||
|
* enum values as before and never has to know GL sent a number.
|
||||||
|
*/
|
||||||
|
export const CHEQUE_BOOK_STATUS_BY_CODE: Record<number, ChequeBookStatus> = {
|
||||||
|
1: ChequeBookStatus.Active,
|
||||||
|
2: ChequeBookStatus.Completed,
|
||||||
|
3: ChequeBookStatus.Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHEQUE_PAGE_ISSUE_STATUS_BY_CODE: Record<number, ChequePageIssueStatus> = {
|
||||||
|
1: ChequePageIssueStatus.Unused,
|
||||||
|
2: ChequePageIssueStatus.Issued,
|
||||||
|
3: ChequePageIssueStatus.Cleared,
|
||||||
|
4: ChequePageIssueStatus.Bounced,
|
||||||
|
5: ChequePageIssueStatus.Cancelled,
|
||||||
|
6: ChequePageIssueStatus.Void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ChequePage.payeeType` is nullable — only set once a page is issued (`06_Enums_Reference.md`). */
|
||||||
|
export const PAYEE_TYPE_BY_CODE: Record<number, PayeeType> = {
|
||||||
|
1: PayeeType.Supplier,
|
||||||
|
2: PayeeType.Customer,
|
||||||
|
3: PayeeType.Employee,
|
||||||
|
4: PayeeType.Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECEIVED_FROM_TYPE_BY_CODE: Record<number, ReceivedFromType> = {
|
||||||
|
1: ReceivedFromType.Customer,
|
||||||
|
2: ReceivedFromType.Supplier,
|
||||||
|
3: ReceivedFromType.Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECEIVED_CHEQUE_STATUS_BY_CODE: Record<number, ReceivedChequeStatus> = {
|
||||||
|
1: ReceivedChequeStatus.Received,
|
||||||
|
2: ReceivedChequeStatus.Deposited,
|
||||||
|
3: ReceivedChequeStatus.Cleared,
|
||||||
|
4: ReceivedChequeStatus.Returned,
|
||||||
|
5: ReceivedChequeStatus.Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
|
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
|
||||||
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
|
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Copy to .env.e2e and fill in real values. Never commit .env.e2e.
|
||||||
|
|
||||||
|
# Frontend origin the browser navigates to (Next.js proxies /api/v1/* server-side from here).
|
||||||
|
E2E_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Backend origin, used only to build absolute API paths in error messages / docs; all
|
||||||
|
# actual requests go through E2E_BASE_URL's same-origin /api/v1 proxy.
|
||||||
|
E2E_API_URL=http://localhost:5224
|
||||||
|
|
||||||
|
# Credentials for a real AuthHex-backed user with access to Receiving, Production, and
|
||||||
|
# Stock modules. AuthHex is an external identity provider (see docs/11-BACKEND-PHASE1.md
|
||||||
|
# §2.0) - there is no local seed for this account, it must already exist upstream.
|
||||||
|
E2E_ADMIN_EMAIL=e2e-tester@example.com
|
||||||
|
E2E_ADMIN_PASSWORD=change-me
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# ERP-Core E2E tests (Playwright)
|
||||||
|
|
||||||
|
End-to-end tests for the three phases requested first: **GRN (receiving)**, **Production
|
||||||
|
runs**, and **Stock movement** (transfers + adjustments), plus one chained scenario that
|
||||||
|
walks all three in sequence. Sales and Accounts are intentionally out of scope for now.
|
||||||
|
|
||||||
|
## Why Playwright, not Selenium
|
||||||
|
|
||||||
|
The frontend is Next.js 16 / React 19. Playwright auto-waits for React state updates,
|
||||||
|
ships trace/video capture on failure, and can drive the backend API directly (used here to
|
||||||
|
seed test data), which made it a better fit than Selenium for this stack.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Backend running locally: `cd Backend/ERPCore && dotnet ef database update && dotnet run`
|
||||||
|
(needs `ASPNETCORE_ENVIRONMENT=Development` set — see the repo's local-env notes — and a
|
||||||
|
reachable Postgres instance). Defaults to `http://localhost:5224`.
|
||||||
|
2. Frontend running locally: `cd Frontend/erp-system && npm install && npm run dev`.
|
||||||
|
Defaults to `http://localhost:3000` and proxies `/api/v1/*` to the backend same-origin.
|
||||||
|
3. A real login for the tests. **Auth is fronted by an external AuthHex identity provider**
|
||||||
|
(`Backend/ERPCore/Controllers/AuthController.cs`) — there is no local seed for a user
|
||||||
|
account, so `E2E_ADMIN_EMAIL`/`E2E_ADMIN_PASSWORD` must be a real, already-provisioned
|
||||||
|
account with access to Receiving, Production, and Stock.
|
||||||
|
4. A fresh-ish database is fine: `DataSeeder` (`Backend/ERPCore/Infra/Persistence/DataSeeder.cs`)
|
||||||
|
seeds the `MAIN`/`SHOP` warehouses, `PCS`/`BOX` UOMs, and a `General Goods` category that
|
||||||
|
these tests rely on existing. Everything else (vendors, items, purchase orders, a
|
||||||
|
production template) is created fresh per run by `support/api.ts` with unique
|
||||||
|
timestamp-suffixed codes, so reruns never collide with previous data.
|
||||||
|
5. **`E2E_BASE_URL` must use `http://localhost`, not `127.0.0.1` or a LAN IP.** The session
|
||||||
|
cookie is written with `Secure = true` unconditionally
|
||||||
|
(`Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs`); Chromium only treats plain-HTTP
|
||||||
|
`localhost` as a secure-enough origin to accept and resend a `Secure` cookie, so anything
|
||||||
|
else silently drops the session and every post-login request 401s.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Testing/e2e
|
||||||
|
npm install
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
cp .env.e2e.example .env.e2e # then fill in E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e # headless, all specs
|
||||||
|
npm run test:e2e:ui # interactive UI mode — best for first-run locator debugging
|
||||||
|
npm run test:e2e:headed # headed browser
|
||||||
|
npm run report # open the last HTML report
|
||||||
|
```
|
||||||
|
|
||||||
|
The `setup` project (`specs/global.setup.ts`) logs in once through the real `/login` form
|
||||||
|
— the session is an httpOnly cookie, so there's no token to inject — and saves it to
|
||||||
|
`.auth/admin.json`. Every other spec's `chromium` project reuses that storage state, so
|
||||||
|
individual specs don't re-authenticate. `auth.spec.ts` is the exception: it explicitly runs
|
||||||
|
with no stored session so it can exercise the login form itself.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Testing/e2e/
|
||||||
|
├── playwright.config.ts
|
||||||
|
├── support/
|
||||||
|
│ ├── env.ts # reads .env.e2e, resolves the storageState path
|
||||||
|
│ └── api.ts # ApiSeeder — creates vendors/items/POs/templates, reads stock on-hand
|
||||||
|
├── pages/ # Page Object Models (one file per module)
|
||||||
|
└── specs/
|
||||||
|
├── global.setup.ts
|
||||||
|
├── auth.spec.ts
|
||||||
|
├── grn.spec.ts
|
||||||
|
├── production.spec.ts
|
||||||
|
├── stock-transfers.spec.ts
|
||||||
|
├── stock-adjustments.spec.ts
|
||||||
|
└── chained-flow.spec.ts # GRN -> Production -> Stock Transfer, one continuous scenario
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage vs. what's deferred
|
||||||
|
|
||||||
|
18 tests across auth, GRN, production runs, stock transfers/adjustments, and one chained
|
||||||
|
flow. Deliberately deferred (all would need a second, multi-stage production template or
|
||||||
|
custom-field scaffolding to exercise, which felt like scope creep for a first pass):
|
||||||
|
|
||||||
|
- **Approve & transfer** on a non-terminal stage, and transferring a held-back remainder
|
||||||
|
from an `Approved` stage — both only apply to a multi-stage graph; the seeded template is
|
||||||
|
single-stage (entry == terminal) so every run here only ever exercises "Approve & receive".
|
||||||
|
- **Reject intake** (pulling back delivered upstream WIP) — same reason, needs a
|
||||||
|
parent→child edge.
|
||||||
|
- Client-side validation edges inside `StageDrawer`: completing a stage with produced qty
|
||||||
|
over the staged input, or a scrap qty with no scrap reason selected.
|
||||||
|
- Adjustment reason-code → ledger-entry tagging spot-check (`GET /stock/ledger`) — the
|
||||||
|
positive/negative adjustment tests verify on-hand moves correctly but don't inspect the
|
||||||
|
ledger rows themselves.
|
||||||
|
|
||||||
|
**Long-run item-dropdown ceiling.** The GRN/Transfer/Adjustment "new" pages load items via
|
||||||
|
`itemsApi.list({ pageSize: 200 })` (a fixed page, not paginated further in the UI). Every
|
||||||
|
spec run mints 2-3 new permanent items through `ApiSeeder.createItem`, and nothing deletes
|
||||||
|
them. Once a dev database accumulates more than 200 active items, freshly-seeded items stop
|
||||||
|
appearing in the Item combobox (and if the list sorts ascending by id, it's exactly the
|
||||||
|
newest ones that fall off) — locators like `getByRole("option", { name: item.name })` will
|
||||||
|
time out with no visible cause. If that starts happening, the fix is to seed one stable
|
||||||
|
per-module item once and reuse it across runs instead of minting a fresh one each time
|
||||||
|
(every assertion here is already delta-based, so that's a drop-in change).
|
||||||
|
|
||||||
|
## Known limitation: a real, reproducible hydration bug
|
||||||
|
|
||||||
|
Every load of the GRN/Production/Stock pages throws a genuine React hydration error
|
||||||
|
("Minified React error #418" — text content mismatch between server and client render).
|
||||||
|
It is **not intermittent** — it fires on every navigation — but its effect is: hydration
|
||||||
|
recovery blanks the placeholder text of a random subset of that page's Select triggers for
|
||||||
|
the rest of that page's life, while leaving the sibling `<Label>`/`<FieldLabel>` and the
|
||||||
|
trigger's `role="combobox"` attribute intact. A `getByRole("combobox", { name: ... })`
|
||||||
|
lookup is therefore unreliable on these pages; `support/ui.ts`'s `comboboxByLabel()` works
|
||||||
|
around it by finding the trigger via its stable sibling label + role alone, never its
|
||||||
|
(possibly-blanked) accessible name. The same file's `retryClick`/`clickToReveal`/
|
||||||
|
`clickToRevealWithReload`/`submitAndWait` cover two related, separately-confirmed issues:
|
||||||
|
short-lived disabled/not-yet-mounted trigger buttons (`RunActions.tsx`'s "Cancel run"/
|
||||||
|
"Return leftover", gated on `run.status`), and stage/document actions whose UI only
|
||||||
|
reflects an async POST once the response lands — reading stock through the API immediately
|
||||||
|
after a click can otherwise race the backend commit. This is worth a look on the product
|
||||||
|
side (root-causing the actual SSR/CSR mismatch would remove the workaround entirely), but
|
||||||
|
was out of scope for a first E2E pass.
|
||||||
|
|
||||||
|
**Backend also can't take concurrent Playwright workers yet.** Reference-data GETs
|
||||||
|
(`/warehouses`, etc.) intermittently 500 when 2+ workers hit a plain `dotnet run` +
|
||||||
|
local Postgres backend at once — confirmed by re-running the exact same suite at
|
||||||
|
`workers: 1` with zero failures. `playwright.config.ts` pins `workers: 1` for that reason;
|
||||||
|
raise it only against a backend that can actually take concurrent load.
|
||||||
|
|
||||||
|
## Known limitation: no `data-testid`s yet
|
||||||
|
|
||||||
|
None of the GRN/Production/Stock Transfer/Stock Adjustment components in
|
||||||
|
`Frontend/erp-system` currently expose `data-testid` attributes, and several form controls
|
||||||
|
have no accessible name at all (the Qty/Unit cost/Disc%/VAT% `<Input type="number">` cells
|
||||||
|
in the GRN and Transfer line tables aren't wrapped in a `<label>` or given `aria-label`).
|
||||||
|
Locators in `pages/` work around this with role/placeholder matching where an accessible
|
||||||
|
name exists, and row + column-position locators (`row.locator('input[type="number"]').nth(n)`)
|
||||||
|
where it doesn't — every such case is called out in a comment at the top of the relevant
|
||||||
|
`pages/*.ts` file, along with the couple of same-text button pairs (e.g. "Cancel run" is
|
||||||
|
both the trigger and the dialog's confirm label) that needed `.first()`/`.last()` to
|
||||||
|
disambiguate. If a component's copy or layout changes, run `npm run test:e2e:ui` to see
|
||||||
|
exactly which locator broke and fix it in `pages/*.ts` — the specs themselves shouldn't need
|
||||||
|
to change.
|
||||||
|
|
||||||
|
**Recommended fast-follow** (not done here, since it's a product-code change rather than a
|
||||||
|
test-authoring one): add `data-testid` to the Select triggers, the Qty/cost inputs, and the
|
||||||
|
line-table rows in the receiving/production/stock components. That would let every locator
|
||||||
|
above swap from role/position matching to exact `data-testid` matching in one pass.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Not wired up yet — no GitHub Actions workflow exists in this repo. Once these specs are
|
||||||
|
green locally, add `.github/workflows/e2e.yml` (spin up Postgres + backend + frontend as
|
||||||
|
services, run `npm run test:e2e`, upload `playwright-report/` as an artifact) as a
|
||||||
|
follow-up.
|
||||||
Generated
+125
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"name": "erp-core-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "erp-core-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "erp-core-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Playwright end-to-end tests for GRN, Production Runs, and Stock Movement flows.",
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"test:e2e:debug": "playwright test --debug",
|
||||||
|
"report": "playwright show-report"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Page, expect } from "@playwright/test"
|
||||||
|
import { clickToReveal, selectOption, comboboxByLabel } from "../support/ui"
|
||||||
|
|
||||||
|
// Locators verified against Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx
|
||||||
|
// and .../grn/[id]/page.tsx. Things that DOM inspection caught and a placeholder-only guess
|
||||||
|
// would not have:
|
||||||
|
// - The Qty/Unit cost/Disc%/VAT% <Input type="number"> cells carry no accessible name
|
||||||
|
// (no htmlFor/aria-label) - located by column position within the row instead.
|
||||||
|
// - On a PO-based line (line.poLineId set) Item/UOM render as plain text, not a Select -
|
||||||
|
// fillFirstLine() only touches the item combobox when one is actually present (checked by
|
||||||
|
// role alone, not name - see below).
|
||||||
|
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||||
|
// random subset of Select triggers' placeholder text for that page's lifetime, WITHOUT
|
||||||
|
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
|
||||||
|
// writeup). So triggers are located via comboboxByLabel() (label + role, no name lookup)
|
||||||
|
// instead of getByRole("combobox", { name }) - the row-scoped item/uom/bin combos have no
|
||||||
|
// adjacent label and are instead found by position, which is equally immune to the bug.
|
||||||
|
export class GrnNewPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto("/dashboard/receiving/grn/new")
|
||||||
|
}
|
||||||
|
|
||||||
|
async useDirectReceipt() {
|
||||||
|
await clickToReveal(
|
||||||
|
this.page.getByRole("button", { name: /direct receipt/i }),
|
||||||
|
comboboxByLabel(this.page, "Vendor")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async useAgainstPo() {
|
||||||
|
await clickToReveal(
|
||||||
|
this.page.getByRole("button", { name: /against po/i }),
|
||||||
|
comboboxByLabel(this.page, "Purchase order")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectVendor(name: string) {
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "Vendor"), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectWarehouse(name: string) {
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `docNo` is what the PO option renders (`{docNo} — Vendor #{vendorId} ({status})`) - not the numeric id. */
|
||||||
|
async selectPurchaseOrder(docNo: string) {
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "Purchase order"), new RegExp(docNo))
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstRow() {
|
||||||
|
return this.page.locator("table tbody tr").first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
|
||||||
|
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
|
||||||
|
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
|
||||||
|
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
|
||||||
|
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
|
||||||
|
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
|
||||||
|
* line needs it set explicitly or submit blocks with "Select a UOM".
|
||||||
|
*/
|
||||||
|
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
|
||||||
|
const row = this.firstRow()
|
||||||
|
const cells = row.locator("td")
|
||||||
|
if (opts.item) {
|
||||||
|
const itemCombo = cells.nth(0).getByRole("combobox")
|
||||||
|
if (await itemCombo.count()) {
|
||||||
|
await selectOption(this.page, itemCombo, opts.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.uom) {
|
||||||
|
const uomCombo = cells.nth(1).getByRole("combobox")
|
||||||
|
if (await uomCombo.count()) {
|
||||||
|
await selectOption(this.page, uomCombo, opts.uom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const numberInputs = row.locator('input[type="number"]')
|
||||||
|
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
|
||||||
|
if (opts.unitCost !== undefined) {
|
||||||
|
await numberInputs.nth(1).fill(String(opts.unitCost)) // Unit cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
await this.page.getByRole("button", { name: /create grn/i }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GrnDetailPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async gotoById(grnId: number) {
|
||||||
|
await this.page.goto(`/dashboard/receiving/grn/${grnId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GrnStatusBadge/HoldStatusBadge render the raw status string verbatim - exact match avoids
|
||||||
|
* matching prose like "Confirmed — stock layers created" in the post-confirm success panel. */
|
||||||
|
async expectStatus(status: "Draft" | "Confirmed") {
|
||||||
|
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm() {
|
||||||
|
await this.page.getByRole("button", { name: /confirm grn/i }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseFirstOnHoldLine() {
|
||||||
|
await this.page.getByRole("button", { name: /^release$/i }).first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectFirstOnHoldLine() {
|
||||||
|
await this.page.getByRole("button", { name: /^reject$/i }).first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectCreateReturnLink() {
|
||||||
|
await expect(this.page.getByRole("link", { name: /create return/i })).toBeVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Page, expect } from "@playwright/test"
|
||||||
|
|
||||||
|
export class LoginPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
await this.page.locator("#email").fill(email)
|
||||||
|
await this.page.locator("#password").fill(password)
|
||||||
|
await this.page.getByRole("button", { name: /sign in/i }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectLoggedIn() {
|
||||||
|
await expect(this.page).toHaveURL(/\/dashboard/)
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectError() {
|
||||||
|
await expect(this.page.getByRole("alert")).toBeVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Page, expect } from "@playwright/test"
|
||||||
|
import { clickToReveal, clickToRevealWithReload, selectOption, comboboxByLabel, submitAndWait } from "../support/ui"
|
||||||
|
|
||||||
|
// Locators verified against Frontend/erp-system/app/dashboard/production/runs/page.tsx,
|
||||||
|
// .../runs/[id]/page.tsx, .../runs/[id]/StageDrawer.tsx, and .../runs/[id]/RunActions.tsx.
|
||||||
|
// Key DOM facts that shaped these locators:
|
||||||
|
// - "Start Run" (the list page's dialog trigger, capital R) and "Start run" (the dialog's
|
||||||
|
// submit button, lowercase r) both match a case-insensitive /start run/i once the dialog
|
||||||
|
// is open (the trigger stays mounted behind it) - the submit click is scoped to
|
||||||
|
// getByRole("dialog") to avoid a strict-mode double match.
|
||||||
|
// - STAGE_STATUS_LABEL.InProgress is "In Progress" - the same text StageStatusLegend
|
||||||
|
// always renders on the run detail page, so a run-status assertion of "In Progress"
|
||||||
|
// collides with the legend. expectStatus() takes the first DOM match, which is always
|
||||||
|
// the run-header badge (it renders before the legend section).
|
||||||
|
// - AlertDialogContent's rejectForRework confirmation reuses "Reject for rework" as both
|
||||||
|
// the trigger and the confirm button's label - first()/last() disambiguates, same as
|
||||||
|
// cancelRun's "Cancel run" trigger/confirm pair.
|
||||||
|
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||||
|
// random subset of Select triggers' placeholder text for that page's lifetime, without
|
||||||
|
// affecting their sibling <FieldLabel> or role="combobox" attribute (support/ui.ts has the
|
||||||
|
// full writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup)
|
||||||
|
// instead of getByRole("combobox", { name }); the scrap-reason Select has no adjacent
|
||||||
|
// label, so it's found via its "Scrapped" sibling block instead.
|
||||||
|
// - Every stage/run action button here fires an async POST that the UI only reflects once the
|
||||||
|
// response lands (StageDrawer/RunActions' `submit()` wrapper) - submitAndWait() (support/ui.ts)
|
||||||
|
// waits for that specific response instead of just the click event, so a test reading stock
|
||||||
|
// right after clicking "Approve & receive" (etc.) doesn't race the backend commit.
|
||||||
|
export class ProductionRunListPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto("/dashboard/production/runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
async openStartRunDialog() {
|
||||||
|
await clickToReveal(
|
||||||
|
this.page.getByRole("button", { name: /start run/i }),
|
||||||
|
this.page.getByRole("dialog")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRun(opts: { template: string; targetQty: number; warehouse: string }) {
|
||||||
|
await this.openStartRunDialog()
|
||||||
|
const dialog = this.page.getByRole("dialog")
|
||||||
|
await selectOption(this.page, comboboxByLabel(dialog, "Template"), opts.template)
|
||||||
|
await dialog.locator("#target-qty").fill(String(opts.targetQty))
|
||||||
|
await selectOption(this.page, comboboxByLabel(dialog, "Warehouse"), opts.warehouse)
|
||||||
|
await submitAndWait(this.page, dialog.getByRole("button", { name: /^start run$/i }), "/production-runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductionRunDetailPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async gotoById(runId: number) {
|
||||||
|
await this.page.goto(`/dashboard/production/runs/${runId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectStatus(status: RegExp | string) {
|
||||||
|
await expect(this.page.getByText(status).first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the StageDrawer for a named stage node on the React Flow canvas. */
|
||||||
|
async openStage(stageName: string) {
|
||||||
|
await clickToReveal(
|
||||||
|
this.page.getByText(stageName, { exact: true }),
|
||||||
|
this.page.getByRole("button", { name: /save quantities/i })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The StageDrawer is a modal Sheet - run-level actions (Return leftover, Cancel run) sit
|
||||||
|
* behind it and need it dismissed first. */
|
||||||
|
async closeStageDrawer() {
|
||||||
|
await this.page.keyboard.press("Escape")
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveQuantities() {
|
||||||
|
// updateStageQuantities is a PUT, unlike every other stage action.
|
||||||
|
await submitAndWait(this.page, this.page.getByRole("button", { name: /save quantities/i }), "/quantities", "PUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
async startStage() {
|
||||||
|
await submitAndWait(this.page, this.page.getByRole("button", { name: /^start stage$/i }), "/start")
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeStage(opts: { producedQty: number; scrappedQty?: number }) {
|
||||||
|
await this.page.getByRole("spinbutton", { name: /produced/i }).first().fill(String(opts.producedQty))
|
||||||
|
if (opts.scrappedQty) {
|
||||||
|
await this.page.getByRole("spinbutton", { name: /scrapped/i }).first().fill(String(opts.scrappedQty))
|
||||||
|
const scrapBlock = this.page.getByText("Scrapped", { exact: true }).locator("../..")
|
||||||
|
await selectOption(this.page, scrapBlock.getByRole("combobox"), /.+/)
|
||||||
|
}
|
||||||
|
await submitAndWait(this.page, this.page.getByRole("button", { name: /complete stage/i }), "/complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveAndReceive() {
|
||||||
|
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*receive/i }), "/approve")
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveAndTransfer() {
|
||||||
|
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*transfer/i }), "/approve")
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectForRework() {
|
||||||
|
const button = this.page.getByRole("button", { name: /^reject for rework$/i })
|
||||||
|
await clickToReveal(button.first(), this.page.getByRole("dialog"))
|
||||||
|
await submitAndWait(this.page, button.last(), "/reject")
|
||||||
|
}
|
||||||
|
|
||||||
|
async openReturnLeftoverDialog() {
|
||||||
|
await clickToRevealWithReload(
|
||||||
|
this.page,
|
||||||
|
this.page.getByRole("button", { name: /return leftover/i }),
|
||||||
|
this.page.getByRole("dialog")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async returnLeftover(opts: { material: string; qty: number; reason: string }) {
|
||||||
|
await this.openReturnLeftoverDialog()
|
||||||
|
const dialog = this.page.getByRole("dialog")
|
||||||
|
await selectOption(this.page, comboboxByLabel(dialog, "Consumed material"), opts.material)
|
||||||
|
await dialog.locator("#return-qty").fill(String(opts.qty))
|
||||||
|
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
|
||||||
|
await submitAndWait(this.page, dialog.getByRole("button", { name: /return to stock/i }), "/return-leftover")
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelRun(opts: { reason: string; note?: string }) {
|
||||||
|
const cancelRunButton = this.page.getByRole("button", { name: /^cancel run$/i })
|
||||||
|
const dialog = this.page.getByRole("dialog")
|
||||||
|
await clickToRevealWithReload(this.page, cancelRunButton.first(), dialog)
|
||||||
|
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
|
||||||
|
if (opts.note) await dialog.locator("#cancel-note").fill(opts.note)
|
||||||
|
await submitAndWait(this.page, cancelRunButton.last(), "/cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Page, expect } from "@playwright/test"
|
||||||
|
import { selectOption, comboboxByLabel } from "../support/ui"
|
||||||
|
|
||||||
|
// Locators verified against Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx,
|
||||||
|
// .../transfers/[id]/page.tsx, and .../stock/adjustments/new/page.tsx.
|
||||||
|
// Every page here intermittently throws a real React hydration error (#418) that blanks a
|
||||||
|
// random subset of Select triggers' placeholder text for that page's lifetime, without
|
||||||
|
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
|
||||||
|
// writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup) instead
|
||||||
|
// of getByRole("combobox", { name }); the row-scoped item combo has no adjacent label and is
|
||||||
|
// instead found by position (it's the first combobox in the row).
|
||||||
|
export class StockTransferNewPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto("/dashboard/stock/transfers/new")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fill(opts: { fromWarehouse: string; toWarehouse: string; item: string; qty: number }) {
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "From warehouse"), opts.fromWarehouse)
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "To warehouse"), opts.toWarehouse)
|
||||||
|
|
||||||
|
const row = this.page.locator("table tbody tr").first()
|
||||||
|
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
|
||||||
|
// The Qty <Input type="number"> carries no accessible name - it's the only number input in the row.
|
||||||
|
await row.locator('input[type="number"]').fill(String(opts.qty))
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
await this.page.getByRole("button", { name: /create transfer/i }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockTransferDetailPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async gotoById(transferId: number) {
|
||||||
|
await this.page.goto(`/dashboard/stock/transfers/${transferId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TransferStatusBadge renders the raw enum literal ("Draft" | "InTransit" | "Received") -
|
||||||
|
* exact match, since "Received" is also a substring of the post-receive success panel's
|
||||||
|
* heading ("Received — destination layers created").
|
||||||
|
*/
|
||||||
|
async expectStatus(status: "Draft" | "InTransit" | "Received") {
|
||||||
|
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch() {
|
||||||
|
await this.page.getByRole("button", { name: /^dispatch$/i }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async receive() {
|
||||||
|
await this.page.getByRole("button", { name: /^receive$/i }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockAdjustmentNewPage {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto("/dashboard/stock/adjustments/new")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fill(opts: { warehouse: string; reasonCode: string; item: string; qtyDelta: number }) {
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), opts.warehouse)
|
||||||
|
await selectOption(this.page, comboboxByLabel(this.page, "Reason code"), opts.reasonCode)
|
||||||
|
|
||||||
|
const row = this.page.locator("table tbody tr").first()
|
||||||
|
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
|
||||||
|
await row.getByPlaceholder(/e\.g\. -15 or 50/i).fill(String(opts.qtyDelta))
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
await this.page.getByRole("button", { name: /post adjustment/i }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectPosted() {
|
||||||
|
await expect(this.page.getByRole("button", { name: /new adjustment/i })).toBeVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test"
|
||||||
|
import { env, AUTH_STORAGE_STATE } from "./support/env"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./specs",
|
||||||
|
fullyParallel: false, // specs share warehouse/item reference data via the ledger - keep runs serial per file
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
// Multiple workers hit the local dev backend concurrently across spec files and it can't
|
||||||
|
// take it: confirmed reference-data GETs (e.g. /warehouses) intermittently 500 under 2+
|
||||||
|
// workers against a plain `dotnet run` + local Postgres, and pass every time at workers: 1.
|
||||||
|
// Bump this only against a backend that can actually take concurrent load (a real CI service
|
||||||
|
// container, not a single dev-mode process).
|
||||||
|
workers: 1,
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
timeout: 45_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: env.baseUrl,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "setup",
|
||||||
|
testMatch: /global\.setup\.ts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"], storageState: AUTH_STORAGE_STATE },
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
import { LoginPage } from "../pages/LoginPage"
|
||||||
|
import { env } from "../support/env"
|
||||||
|
|
||||||
|
// Runs unauthenticated - unlike every other spec, it must not use the "chromium" project's
|
||||||
|
// saved storageState, since it is exercising the login form itself.
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } })
|
||||||
|
|
||||||
|
test.describe("Login", () => {
|
||||||
|
test("valid credentials redirect to the dashboard", async ({ page }) => {
|
||||||
|
const login = new LoginPage(page)
|
||||||
|
await login.goto()
|
||||||
|
await login.login(env.adminEmail, env.adminPassword)
|
||||||
|
await login.expectLoggedIn()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid password shows an inline error and stays on /login", async ({ page }) => {
|
||||||
|
const login = new LoginPage(page)
|
||||||
|
await login.goto()
|
||||||
|
await login.login(env.adminEmail, "definitely-not-the-password")
|
||||||
|
await login.expectError()
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session-expired redirect shows the amber notice", async ({ page }) => {
|
||||||
|
await page.goto("/login?next=/dashboard/receiving/grn")
|
||||||
|
await expect(page.getByText(/session is missing or expired/i)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||||
|
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||||
|
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
|
||||||
|
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
|
||||||
|
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
|
||||||
|
|
||||||
|
// Full cross-module lifecycle: GRN receipt -> Production consumes the received stock and
|
||||||
|
// produces a finished good -> Stock Transfer moves the finished good to a second warehouse.
|
||||||
|
// All four modules post to the same StockLayer/StockLedger tables (docs/10 C.9), so this is
|
||||||
|
// the scenario most likely to catch a regression in one module's ledger posting breaking
|
||||||
|
// another's downstream read - the thing the per-module suites (grn.spec.ts,
|
||||||
|
// production.spec.ts, stock-transfers.spec.ts) can't see in isolation.
|
||||||
|
test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||||
|
let api: APIRequestContext
|
||||||
|
let seeder: ApiSeeder
|
||||||
|
let sourceWarehouse: Warehouse
|
||||||
|
let destWarehouse: Warehouse
|
||||||
|
let vendor: Vendor
|
||||||
|
let uom: Uom
|
||||||
|
let rawItem: Item
|
||||||
|
let finishedItem: Item
|
||||||
|
let templateName: string
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
api = await newApiContext()
|
||||||
|
seeder = new ApiSeeder(api)
|
||||||
|
sourceWarehouse = await seeder.firstWarehouse()
|
||||||
|
destWarehouse = await seeder.secondWarehouse()
|
||||||
|
uom = await seeder.firstUom()
|
||||||
|
vendor = await seeder.createVendor("Chained Flow Vendor")
|
||||||
|
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
|
||||||
|
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
|
||||||
|
|
||||||
|
const template = await seeder.createSingleStageTemplate({
|
||||||
|
rawItemId: rawItem.itemId,
|
||||||
|
finishedItemId: finishedItem.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
})
|
||||||
|
templateName = template.name
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await api.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("receive raw material, run production, transfer the finished good", async ({ page }) => {
|
||||||
|
// --- 1. GRN: receive the raw material into the source warehouse -----------------
|
||||||
|
const grnNew = new GrnNewPage(page)
|
||||||
|
await grnNew.goto()
|
||||||
|
await grnNew.useDirectReceipt()
|
||||||
|
await grnNew.selectVendor(vendor.name)
|
||||||
|
await grnNew.selectWarehouse(sourceWarehouse.name)
|
||||||
|
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
|
||||||
|
await grnNew.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||||
|
const grnDetail = new GrnDetailPage(page)
|
||||||
|
await grnDetail.expectStatus("Draft")
|
||||||
|
await grnDetail.confirm()
|
||||||
|
await grnDetail.expectStatus("Confirmed")
|
||||||
|
|
||||||
|
const rawAfterGrn = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
|
||||||
|
expect(rawAfterGrn.onHand).toBeCloseTo(100, 4)
|
||||||
|
|
||||||
|
// --- 2. Production: consume the raw material, produce the finished good ---------
|
||||||
|
const runList = new ProductionRunListPage(page)
|
||||||
|
await runList.goto()
|
||||||
|
await runList.startRun({ template: templateName, targetQty: 20, warehouse: sourceWarehouse.name })
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||||
|
const runDetail = new ProductionRunDetailPage(page)
|
||||||
|
await runDetail.expectStatus(/in progress/i)
|
||||||
|
|
||||||
|
await runDetail.openStage("Assemble")
|
||||||
|
await runDetail.saveQuantities()
|
||||||
|
await runDetail.startStage()
|
||||||
|
|
||||||
|
const rawAfterStart = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
|
||||||
|
expect(rawAfterStart.onHand).toBeLessThan(rawAfterGrn.onHand)
|
||||||
|
|
||||||
|
await runDetail.completeStage({ producedQty: 20 })
|
||||||
|
await runDetail.approveAndReceive()
|
||||||
|
|
||||||
|
const finishedAfterRun = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
|
||||||
|
expect(finishedAfterRun.onHand).toBeCloseTo(20, 4)
|
||||||
|
|
||||||
|
// --- 3. Stock Transfer: move the finished good to a second warehouse ------------
|
||||||
|
const transferNew = new StockTransferNewPage(page)
|
||||||
|
await transferNew.goto()
|
||||||
|
await transferNew.fill({
|
||||||
|
fromWarehouse: sourceWarehouse.name,
|
||||||
|
toWarehouse: destWarehouse.name,
|
||||||
|
item: finishedItem.name,
|
||||||
|
qty: 20,
|
||||||
|
})
|
||||||
|
await transferNew.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||||
|
const transferDetail = new StockTransferDetailPage(page)
|
||||||
|
await transferDetail.expectStatus("Draft")
|
||||||
|
await transferDetail.dispatch()
|
||||||
|
await transferDetail.expectStatus("InTransit")
|
||||||
|
await transferDetail.receive()
|
||||||
|
await transferDetail.expectStatus("Received")
|
||||||
|
|
||||||
|
// --- 4. Final assertions across the whole chain ----------------------------------
|
||||||
|
const finishedAtSource = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
|
||||||
|
const finishedAtDest = await seeder.stockOnHand(finishedItem.itemId, destWarehouse.warehouseId)
|
||||||
|
expect(finishedAtSource.onHand).toBeCloseTo(0, 4)
|
||||||
|
expect(finishedAtDest.onHand).toBeCloseTo(20, 4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test as setup } from "@playwright/test"
|
||||||
|
import { LoginPage } from "../pages/LoginPage"
|
||||||
|
import { env, AUTH_STORAGE_STATE } from "../support/env"
|
||||||
|
|
||||||
|
// Runs once before the "chromium" project (see playwright.config.ts `dependencies`). Logs
|
||||||
|
// in through the real UI form - the session is an httpOnly cookie (docs/11 §2.0), so there
|
||||||
|
// is no token to inject; driving the form is the only way to obtain it - then saves cookies
|
||||||
|
// to disk so every other spec starts already authenticated.
|
||||||
|
setup("authenticate", async ({ page }) => {
|
||||||
|
const login = new LoginPage(page)
|
||||||
|
await login.goto()
|
||||||
|
await login.login(env.adminEmail, env.adminPassword)
|
||||||
|
await login.expectLoggedIn()
|
||||||
|
|
||||||
|
await page.context().storageState({ path: AUTH_STORAGE_STATE })
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||||
|
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api"
|
||||||
|
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
|
||||||
|
|
||||||
|
// GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend
|
||||||
|
// app/dashboard/receiving/grn/*). Covers direct + against-PO receipts, confirm posting to
|
||||||
|
// the stock ledger, and per-line hold-status actions.
|
||||||
|
test.describe("GRN", () => {
|
||||||
|
let api: APIRequestContext
|
||||||
|
let seeder: ApiSeeder
|
||||||
|
let warehouse: Warehouse
|
||||||
|
let vendor: Vendor
|
||||||
|
let uom: Uom
|
||||||
|
let item: Item
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
api = await newApiContext()
|
||||||
|
seeder = new ApiSeeder(api)
|
||||||
|
warehouse = await seeder.firstWarehouse()
|
||||||
|
uom = await seeder.firstUom()
|
||||||
|
vendor = await seeder.createVendor()
|
||||||
|
item = await seeder.createItem({ namePrefix: "GRN Test Item" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await api.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct receipt creates a Draft GRN, confirm posts stock", async ({ page }) => {
|
||||||
|
const grnNew = new GrnNewPage(page)
|
||||||
|
await grnNew.goto()
|
||||||
|
await grnNew.useDirectReceipt()
|
||||||
|
await grnNew.selectVendor(vendor.name)
|
||||||
|
await grnNew.selectWarehouse(warehouse.name)
|
||||||
|
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 })
|
||||||
|
await grnNew.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||||
|
const grnDetail = new GrnDetailPage(page)
|
||||||
|
await grnDetail.expectStatus("Draft")
|
||||||
|
|
||||||
|
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
|
||||||
|
await grnDetail.confirm()
|
||||||
|
await grnDetail.expectStatus("Confirmed")
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("against-PO receipt pre-fills vendor/warehouse from the PO", async ({ page }) => {
|
||||||
|
const po = await seeder.createPurchaseOrder({
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
warehouseId: warehouse.warehouseId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 5,
|
||||||
|
unitPrice: 40,
|
||||||
|
})
|
||||||
|
|
||||||
|
const grnNew = new GrnNewPage(page)
|
||||||
|
await grnNew.goto()
|
||||||
|
await grnNew.useAgainstPo()
|
||||||
|
await grnNew.selectPurchaseOrder(po.docNo)
|
||||||
|
await grnNew.fillFirstLine({ item: item.name, qty: 5 })
|
||||||
|
await grnNew.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||||
|
const grnDetail = new GrnDetailPage(page)
|
||||||
|
await grnDetail.expectStatus("Draft")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejecting a Draft GRN's blocked submit (missing warehouse) keeps the user on the form", async ({ page }) => {
|
||||||
|
const grnNew = new GrnNewPage(page)
|
||||||
|
await grnNew.goto()
|
||||||
|
await grnNew.useDirectReceipt()
|
||||||
|
await grnNew.selectVendor(vendor.name)
|
||||||
|
// Warehouse intentionally left unselected.
|
||||||
|
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 })
|
||||||
|
await grnNew.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("releasing an on-hold line clears the hold and makes stock available", async ({ page }) => {
|
||||||
|
const grn = await seeder.receiveStockOnHold({
|
||||||
|
warehouseId: warehouse.warehouseId,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 8,
|
||||||
|
unitCost: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
const grnDetail = new GrnDetailPage(page)
|
||||||
|
await grnDetail.gotoById(grn.grnId)
|
||||||
|
await grnDetail.expectStatus("Confirmed")
|
||||||
|
|
||||||
|
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
expect(before.available).toBeLessThan(before.onHand) // held stock is on-hand but not available
|
||||||
|
|
||||||
|
await grnDetail.releaseFirstOnHoldLine()
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.available).toBeCloseTo(before.available + 8, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejecting an on-hold line surfaces a Create Return link", async ({ page }) => {
|
||||||
|
const grn = await seeder.receiveStockOnHold({
|
||||||
|
warehouseId: warehouse.warehouseId,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 3,
|
||||||
|
unitCost: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
const grnDetail = new GrnDetailPage(page)
|
||||||
|
await grnDetail.gotoById(grn.grnId)
|
||||||
|
await grnDetail.expectStatus("Confirmed")
|
||||||
|
|
||||||
|
await grnDetail.rejectFirstOnHoldLine()
|
||||||
|
await grnDetail.expectCreateReturnLink()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||||
|
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||||
|
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
|
||||||
|
|
||||||
|
// Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs,
|
||||||
|
// Frontend app/dashboard/production/runs/*). Uses a minimal single-stage template (one
|
||||||
|
// stage that is both entry and terminal - see ApiSeeder.createSingleStageTemplate) so the
|
||||||
|
// stage-action sequence (start -> complete -> approve & receive) is exercised without
|
||||||
|
// needing a multi-stage graph.
|
||||||
|
test.describe("Production runs", () => {
|
||||||
|
let api: APIRequestContext
|
||||||
|
let seeder: ApiSeeder
|
||||||
|
let warehouse: Warehouse
|
||||||
|
let vendor: Vendor
|
||||||
|
let uom: Uom
|
||||||
|
let rawItem: Item
|
||||||
|
let finishedItem: Item
|
||||||
|
let templateName: string
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
api = await newApiContext()
|
||||||
|
seeder = new ApiSeeder(api)
|
||||||
|
warehouse = await seeder.firstWarehouse()
|
||||||
|
uom = await seeder.firstUom()
|
||||||
|
vendor = await seeder.createVendor()
|
||||||
|
rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" })
|
||||||
|
finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" })
|
||||||
|
|
||||||
|
// Give the run something to consume.
|
||||||
|
await seeder.receiveStock({
|
||||||
|
warehouseId: warehouse.warehouseId,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
itemId: rawItem.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 100,
|
||||||
|
unitCost: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const template = await seeder.createSingleStageTemplate({
|
||||||
|
rawItemId: rawItem.itemId,
|
||||||
|
finishedItemId: finishedItem.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
})
|
||||||
|
templateName = template.name
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await api.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("start run -> complete stage -> approve & receive posts finished-good stock", async ({ page }) => {
|
||||||
|
const list = new ProductionRunListPage(page)
|
||||||
|
await list.goto()
|
||||||
|
await list.startRun({ template: templateName, targetQty: 10, warehouse: warehouse.name })
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||||
|
const detail = new ProductionRunDetailPage(page)
|
||||||
|
await detail.expectStatus(/in progress/i)
|
||||||
|
|
||||||
|
await detail.openStage("Assemble")
|
||||||
|
await detail.saveQuantities()
|
||||||
|
await detail.startStage()
|
||||||
|
|
||||||
|
const before = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
|
||||||
|
|
||||||
|
await detail.completeStage({ producedQty: 10 })
|
||||||
|
await detail.approveAndReceive()
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancel run stops further stage actions", async ({ page }) => {
|
||||||
|
const list = new ProductionRunListPage(page)
|
||||||
|
await list.goto()
|
||||||
|
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||||
|
|
||||||
|
const detail = new ProductionRunDetailPage(page)
|
||||||
|
await detail.cancelRun({ reason: "Production Run Cancelled", note: "E2E cancel test" })
|
||||||
|
await detail.expectStatus(/cancelled/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("return leftover raw material posts the unused quantity back to stock", async ({ page }) => {
|
||||||
|
const list = new ProductionRunListPage(page)
|
||||||
|
await list.goto()
|
||||||
|
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||||
|
|
||||||
|
const detail = new ProductionRunDetailPage(page)
|
||||||
|
await detail.openStage("Assemble")
|
||||||
|
await detail.saveQuantities()
|
||||||
|
await detail.startStage() // consumes the raw-material FIFO layers, making them returnable
|
||||||
|
await detail.closeStageDrawer()
|
||||||
|
|
||||||
|
const before = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
|
||||||
|
|
||||||
|
await detail.returnLeftover({ material: rawItem.name, qty: 1, reason: "Production Leftover Return" })
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.onHand).toBeCloseTo(before.onHand + 1, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reject for rework resets the run and increments the rework count", async ({ page }) => {
|
||||||
|
const list = new ProductionRunListPage(page)
|
||||||
|
await list.goto()
|
||||||
|
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
|
||||||
|
|
||||||
|
const detail = new ProductionRunDetailPage(page)
|
||||||
|
await detail.openStage("Assemble")
|
||||||
|
await detail.saveQuantities()
|
||||||
|
await detail.startStage()
|
||||||
|
await detail.completeStage({ producedQty: 5 }) // stage -> Done, and terminal (single-stage template)
|
||||||
|
|
||||||
|
await detail.rejectForRework()
|
||||||
|
await detail.expectStatus(/rework #1/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||||
|
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||||
|
import { StockAdjustmentNewPage } from "../pages/StockPages"
|
||||||
|
|
||||||
|
// Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs,
|
||||||
|
// Frontend app/dashboard/stock/adjustments/new): posts immediately, no draft state
|
||||||
|
// (Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs - QtyDelta is a signed base-UOM delta).
|
||||||
|
test.describe("Stock adjustments", () => {
|
||||||
|
let api: APIRequestContext
|
||||||
|
let seeder: ApiSeeder
|
||||||
|
let warehouse: Warehouse
|
||||||
|
let vendor: Vendor
|
||||||
|
let uom: Uom
|
||||||
|
let item: Item
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
api = await newApiContext()
|
||||||
|
seeder = new ApiSeeder(api)
|
||||||
|
warehouse = await seeder.firstWarehouse()
|
||||||
|
uom = await seeder.firstUom()
|
||||||
|
vendor = await seeder.createVendor()
|
||||||
|
item = await seeder.createItem({ namePrefix: "Adjustment Test Item" })
|
||||||
|
|
||||||
|
await seeder.receiveStock({
|
||||||
|
warehouseId: warehouse.warehouseId,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 20,
|
||||||
|
unitCost: 30,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await api.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("positive adjustment increases on-hand and shows the posted doc number", async ({ page }) => {
|
||||||
|
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
|
||||||
|
const newPage = new StockAdjustmentNewPage(page)
|
||||||
|
await newPage.goto()
|
||||||
|
await newPage.fill({ warehouse: warehouse.name, reasonCode: "System Correction", item: item.name, qtyDelta: 5 })
|
||||||
|
await newPage.submit()
|
||||||
|
|
||||||
|
await newPage.expectPosted()
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.onHand).toBeCloseTo(before.onHand + 5, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("negative adjustment decreases on-hand", async ({ page }) => {
|
||||||
|
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
|
||||||
|
const newPage = new StockAdjustmentNewPage(page)
|
||||||
|
await newPage.goto()
|
||||||
|
await newPage.fill({ warehouse: warehouse.name, reasonCode: "Damage", item: item.name, qtyDelta: -3 })
|
||||||
|
await newPage.submit()
|
||||||
|
|
||||||
|
await newPage.expectPosted()
|
||||||
|
|
||||||
|
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
|
||||||
|
expect(after.onHand).toBeCloseTo(before.onHand - 3, 4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||||
|
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||||
|
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
|
||||||
|
|
||||||
|
// Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend
|
||||||
|
// app/dashboard/stock/transfers/*): Draft -> Dispatch -> Receive, moving FIFO layers
|
||||||
|
// between warehouses.
|
||||||
|
test.describe("Stock transfers", () => {
|
||||||
|
let api: APIRequestContext
|
||||||
|
let seeder: ApiSeeder
|
||||||
|
let srcWarehouse: Warehouse
|
||||||
|
let destWarehouse: Warehouse
|
||||||
|
let vendor: Vendor
|
||||||
|
let uom: Uom
|
||||||
|
let item: Item
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
api = await newApiContext()
|
||||||
|
seeder = new ApiSeeder(api)
|
||||||
|
srcWarehouse = await seeder.firstWarehouse()
|
||||||
|
destWarehouse = await seeder.secondWarehouse()
|
||||||
|
uom = await seeder.firstUom()
|
||||||
|
vendor = await seeder.createVendor()
|
||||||
|
item = await seeder.createItem({ namePrefix: "Transfer Test Item" })
|
||||||
|
|
||||||
|
await seeder.receiveStock({
|
||||||
|
warehouseId: srcWarehouse.warehouseId,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
uomId: uom.uomId,
|
||||||
|
qty: 50,
|
||||||
|
unitCost: 15,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await api.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("create -> dispatch -> receive moves stock between warehouses", async ({ page }) => {
|
||||||
|
const before = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
|
||||||
|
|
||||||
|
const newPage = new StockTransferNewPage(page)
|
||||||
|
await newPage.goto()
|
||||||
|
await newPage.fill({ fromWarehouse: srcWarehouse.name, toWarehouse: destWarehouse.name, item: item.name, qty: 10 })
|
||||||
|
await newPage.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||||
|
const detail = new StockTransferDetailPage(page)
|
||||||
|
await detail.expectStatus("Draft")
|
||||||
|
|
||||||
|
await detail.dispatch()
|
||||||
|
await detail.expectStatus("InTransit")
|
||||||
|
|
||||||
|
const afterDispatch = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
|
||||||
|
// Dispatch consumes the source FIFO layers immediately (Backend/ERPCore/Services/Stock/
|
||||||
|
// StockService.cs: "Dispatch already consumed the source layers, so this stock has left
|
||||||
|
// onHand") - inTransit is reported for visibility only, not held back from onHand.
|
||||||
|
expect(afterDispatch.onHand).toBeCloseTo(before.onHand - 10, 4)
|
||||||
|
|
||||||
|
await detail.receive()
|
||||||
|
await detail.expectStatus("Received")
|
||||||
|
|
||||||
|
const destAfter = await seeder.stockOnHand(item.itemId, destWarehouse.warehouseId)
|
||||||
|
expect(destAfter.onHand).toBeGreaterThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dispatch fails with insufficient stock and the transfer stays Draft", async ({ page }) => {
|
||||||
|
const newPage = new StockTransferNewPage(page)
|
||||||
|
await newPage.goto()
|
||||||
|
await newPage.fill({
|
||||||
|
fromWarehouse: srcWarehouse.name,
|
||||||
|
toWarehouse: destWarehouse.name,
|
||||||
|
item: item.name,
|
||||||
|
qty: 999_999,
|
||||||
|
})
|
||||||
|
await newPage.submit()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
|
||||||
|
const detail = new StockTransferDetailPage(page)
|
||||||
|
await detail.expectStatus("Draft")
|
||||||
|
|
||||||
|
await detail.dispatch()
|
||||||
|
|
||||||
|
// FifoCostingService rejects with 409 STOCK_NEGATIVE_BLOCKED - the frontend surfaces the
|
||||||
|
// error and leaves the transfer in Draft rather than advancing it.
|
||||||
|
await detail.expectStatus("Draft")
|
||||||
|
await expect(page.getByRole("button", { name: /^dispatch$/i })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { APIRequestContext, expect, request } from "@playwright/test"
|
||||||
|
import { env, AUTH_STORAGE_STATE } from "./env"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone APIRequestContext for use in `test.beforeAll`, where the test-scoped `request`
|
||||||
|
* fixture isn't available. Reuses the same storageState the "setup" project produced, so it
|
||||||
|
* is already authenticated. Caller must `.dispose()` it in `afterAll`.
|
||||||
|
*/
|
||||||
|
export async function newApiContext(): Promise<APIRequestContext> {
|
||||||
|
return request.newContext({ baseURL: env.baseUrl, storageState: AUTH_STORAGE_STATE })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin wrapper over the same `/api/v1` surface `Frontend/erp-system/lib/api/*.ts` calls,
|
||||||
|
// used to seed/verify data directly against the backend so specs don't have to build every
|
||||||
|
// prerequisite (vendors, POs, templates) by driving the UI. `request` must already carry
|
||||||
|
// the authenticated session cookie - either via the "setup" project's storageState, or by
|
||||||
|
// passing a context created after `AuthApi.login`.
|
||||||
|
const API_BASE = "/api/v1"
|
||||||
|
|
||||||
|
export interface Warehouse {
|
||||||
|
warehouseId: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vendor {
|
||||||
|
vendorId: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
itemId: number
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
baseUomId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Uom {
|
||||||
|
uomId: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
categoryId: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suffixes every seeded code/SKU with a run-unique token so parallel/rerun specs never collide. */
|
||||||
|
export function uniqueSuffix(): string {
|
||||||
|
return `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiSeeder {
|
||||||
|
constructor(private readonly request: APIRequestContext) {}
|
||||||
|
|
||||||
|
private async get<T>(path: string): Promise<T> {
|
||||||
|
const res = await this.request.get(`${API_BASE}${path}`)
|
||||||
|
expect(res.ok(), `GET ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, data: unknown): Promise<T> {
|
||||||
|
const res = await this.request.post(`${API_BASE}${path}`, { data })
|
||||||
|
expect(res.ok(), `POST ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reference data (relies on DataSeeder's MAIN/SHOP/PCS/BOX/General Goods seed) -----
|
||||||
|
|
||||||
|
async firstWarehouse(): Promise<Warehouse> {
|
||||||
|
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=1")
|
||||||
|
if (!page.items.length) throw new Error("No warehouses found - expected DataSeeder's MAIN warehouse to exist.")
|
||||||
|
return page.items[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async secondWarehouse(): Promise<Warehouse> {
|
||||||
|
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=10")
|
||||||
|
if (page.items.length < 2) throw new Error("Need at least 2 warehouses (DataSeeder seeds MAIN + SHOP).")
|
||||||
|
return page.items[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUom(): Promise<Uom> {
|
||||||
|
const page = await this.get<{ items: Uom[] }>("/uoms?page=1&pageSize=1")
|
||||||
|
if (!page.items.length) throw new Error("No UOMs found - expected DataSeeder's PCS uom to exist.")
|
||||||
|
return page.items[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstCategory(): Promise<Category> {
|
||||||
|
const page = await this.get<{ items: Category[] }>("/categories?page=1&pageSize=1")
|
||||||
|
if (!page.items.length) throw new Error("No categories found - expected DataSeeder's General Goods category.")
|
||||||
|
return page.items[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes used to build test fixtures -------------------------------------------
|
||||||
|
|
||||||
|
async createVendor(namePrefix = "E2E Vendor"): Promise<Vendor> {
|
||||||
|
const suffix = uniqueSuffix()
|
||||||
|
return this.post<Vendor>("/vendors", {
|
||||||
|
code: `E2E-V-${suffix}`,
|
||||||
|
name: `${namePrefix} ${suffix}`,
|
||||||
|
currency: "LKR",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async createItem(opts: { namePrefix?: string; categoryId?: number; baseUomId?: number } = {}): Promise<Item> {
|
||||||
|
const suffix = uniqueSuffix()
|
||||||
|
const categoryId = opts.categoryId ?? (await this.firstCategory()).categoryId
|
||||||
|
const baseUomId = opts.baseUomId ?? (await this.firstUom()).uomId
|
||||||
|
return this.post<Item>("/items", {
|
||||||
|
sku: `E2E-SKU-${suffix}`,
|
||||||
|
name: `${opts.namePrefix ?? "E2E Item"} ${suffix}`,
|
||||||
|
categoryId,
|
||||||
|
baseUomId,
|
||||||
|
stockNature: "Stocked",
|
||||||
|
trackingMode: "None",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */
|
||||||
|
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||||
|
const grn = await this.post<{ grnId: number }>("/grns", {
|
||||||
|
vendorId: opts.vendorId,
|
||||||
|
warehouseId: opts.warehouseId,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: opts.itemId,
|
||||||
|
uomId: opts.uomId,
|
||||||
|
qty: opts.qty,
|
||||||
|
unitCost: opts.unitCost,
|
||||||
|
discountPct: 0,
|
||||||
|
vatPct: 0,
|
||||||
|
holdStatus: "Available",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||||
|
return grn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */
|
||||||
|
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||||
|
const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", {
|
||||||
|
vendorId: opts.vendorId,
|
||||||
|
warehouseId: opts.warehouseId,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: opts.itemId,
|
||||||
|
uomId: opts.uomId,
|
||||||
|
qty: opts.qty,
|
||||||
|
unitCost: opts.unitCost,
|
||||||
|
discountPct: 0,
|
||||||
|
vatPct: 0,
|
||||||
|
holdStatus: "OnHold",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||||
|
return grn
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) {
|
||||||
|
return this.post<{ poId: number; docNo: string }>("/purchase-orders", {
|
||||||
|
vendorId: opts.vendorId,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: opts.itemId,
|
||||||
|
uomId: opts.uomId,
|
||||||
|
warehouseId: opts.warehouseId,
|
||||||
|
qty: opts.qty,
|
||||||
|
unitPrice: opts.unitPrice,
|
||||||
|
tax: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal single-stage template: one stage that is both entry and terminal, one Stock
|
||||||
|
* input (the raw material) and one item-bearing output (the finished good) - the
|
||||||
|
* smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/
|
||||||
|
* ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output).
|
||||||
|
*/
|
||||||
|
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) {
|
||||||
|
const suffix = uniqueSuffix()
|
||||||
|
return this.post<{ templateId: number; code: string; name: string }>("/production-templates", {
|
||||||
|
code: `E2E-TPL-${suffix}`,
|
||||||
|
name: `E2E Template ${suffix}`,
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
key: "stage-1",
|
||||||
|
name: "Assemble",
|
||||||
|
estimatedMinutes: 10,
|
||||||
|
posX: 0,
|
||||||
|
posY: 0,
|
||||||
|
fieldDefs: [],
|
||||||
|
inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||||
|
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
annotations: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async stockOnHand(itemId: number, warehouseId: number) {
|
||||||
|
return this.get<{ onHand: number; available: number }>(`/stock/on-hand?itemId=${itemId}&warehouseId=${warehouseId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../.env.e2e") })
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name]
|
||||||
|
if (!value) throw new Error(`Missing required env var ${name} - copy .env.e2e.example to .env.e2e and fill it in.`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
baseUrl: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||||
|
apiUrl: process.env.E2E_API_URL ?? "http://localhost:5224",
|
||||||
|
get adminEmail() {
|
||||||
|
return required("E2E_ADMIN_EMAIL")
|
||||||
|
},
|
||||||
|
get adminPassword() {
|
||||||
|
return required("E2E_ADMIN_PASSWORD")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_STORAGE_STATE = path.resolve(__dirname, "../.auth/admin.json")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root cause (confirmed via a repro script capturing `page.on("pageerror")`): every load of
|
||||||
|
* these pages throws a genuine React hydration error ("Minified React error #418" - text
|
||||||
|
* content mismatch between server and client render) - it is NOT intermittent. What IS
|
||||||
|
* unpredictable is its effect: hydration recovery blanks the placeholder text of a random
|
||||||
|
* subset of that page's Select triggers, but leaves everything else (the sibling <Label>/
|
||||||
|
* <FieldLabel>, the trigger's role="combobox" attribute, the DOM structure) intact. So a
|
||||||
|
* reload-until-clean strategy never terminates (confirmed: reloading never once produced a
|
||||||
|
* "clean" load), and a bare `getByRole("combobox", { name: ... })` is unreliable because the
|
||||||
|
* accessible name it depends on is exactly what gets blanked.
|
||||||
|
*
|
||||||
|
* The fix is to stop depending on that name at all: every Select trigger in this app sits as
|
||||||
|
* an immediate sibling of a stable, always-intact label element, so `comboboxByLabel()` finds
|
||||||
|
* the trigger via that label + role="combobox" alone. `retryClick`/`selectOption`/
|
||||||
|
* `clickToReveal` remain useful as defense-in-depth for ordinary timing races (dialogs
|
||||||
|
* mounting, popups opening) that are unrelated to this hydration bug.
|
||||||
|
*/
|
||||||
|
export function comboboxByLabel(scope: Page | Locator, labelText: string): Locator {
|
||||||
|
return scope.getByText(labelText, { exact: true }).locator("..").getByRole("combobox").first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `trigger.click()` gets an explicit, short per-attempt timeout deliberately: without one, a
|
||||||
|
* momentarily-disabled/not-yet-actionable button (e.g. a trigger that's disabled for one tick
|
||||||
|
* after navigation before client state settles) lets a SINGLE click() call sit and retry
|
||||||
|
* internally for the whole remaining test timeout, so this loop never reaches a second attempt
|
||||||
|
* - confirmed happening on "Cancel run" right after starting a run. A short click timeout lets
|
||||||
|
* the loop actually cycle through multiple real attempts within the test's time budget.
|
||||||
|
*/
|
||||||
|
export async function retryClick(trigger: Locator, verify: () => Promise<void>, attempts = 5) {
|
||||||
|
let lastErr: unknown
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
await trigger.click({ timeout: 3000 })
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await verify()
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a Select trigger and picks an option by name. Deliberately a single click, not a
|
||||||
|
* retryClick loop: re-clicking an already-open Select trigger toggles it shut, and if the
|
||||||
|
* option name is ever wrong the popup's portal can end up overlaying the trigger, making
|
||||||
|
* Playwright's actionability check for a second click hang indefinitely (confirmed while
|
||||||
|
* testing this file) instead of failing fast. `comboboxByLabel` already makes the trigger
|
||||||
|
* lookup itself reliable, so a plain click here is both simpler and safer.
|
||||||
|
*/
|
||||||
|
export async function selectOption(page: Page, trigger: Locator, optionName: string | RegExp) {
|
||||||
|
// .first() covers callers that intentionally pass a name matching multiple options (e.g. "pick
|
||||||
|
// any scrap reason") - for the common single-match case it's a no-op.
|
||||||
|
const option = page.getByRole("option", { name: optionName }).first()
|
||||||
|
await trigger.click()
|
||||||
|
await option.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicks a trigger that's expected to reveal `target` (a dialog, a newly-mounted control), retrying the click. */
|
||||||
|
export async function clickToReveal(trigger: Locator, target: Locator) {
|
||||||
|
await retryClick(trigger, () => target.waitFor({ state: "visible", timeout: 1500 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as `clickToReveal`, but escalates to a full `page.reload()` between rounds when the
|
||||||
|
* trigger itself never becomes actionable within a round - not just "the popup didn't open"
|
||||||
|
* but "the trigger stayed disabled" or "never rendered at all". Confirmed on the production run
|
||||||
|
* detail page's "Cancel run"/"Return leftover" buttons (RunActions.tsx, gated on
|
||||||
|
* `run.status === "InProgress"`): occasionally that gate/enabled-state renders wrong for the
|
||||||
|
* rest of a page's life - the same class of one-shot render corruption as the hydration bug
|
||||||
|
* documented above, just hitting a component's disabled/mounted state instead of a Select's
|
||||||
|
* placeholder text. A reload gets a fresh render attempt; `trigger`/`target` are re-queried
|
||||||
|
* live each round since Playwright locators aren't tied to a specific DOM snapshot.
|
||||||
|
*/
|
||||||
|
export async function clickToRevealWithReload(page: Page, trigger: Locator, target: Locator, reloadAttempts = 3) {
|
||||||
|
let lastErr: unknown
|
||||||
|
for (let i = 0; i < reloadAttempts; i++) {
|
||||||
|
try {
|
||||||
|
await clickToReveal(trigger, target)
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e
|
||||||
|
if (i < reloadAttempts - 1) await page.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every stage-action / document-action button in this app fires an async POST and only updates
|
||||||
|
* the DOM once the response comes back (`onActed()`/`onSuccess()` refetch pattern) - Playwright's
|
||||||
|
* `.click()` resolves as soon as the click event dispatches, NOT once that request settles. A
|
||||||
|
* test that clicks "Approve & receive" and immediately reads stock through a separate API call
|
||||||
|
* can race ahead of the backend commit and observe pre-action state (confirmed: production run
|
||||||
|
* stock checks reading 0 immediately after a click the UI later shows as successful). Wrapping
|
||||||
|
* the click in `page.waitForResponse` for the specific endpoint makes the helper actually wait
|
||||||
|
* for the request that matters, not just the DOM event.
|
||||||
|
*/
|
||||||
|
export async function submitAndWait(page: Page, trigger: Locator, urlIncludes: string, method: "POST" | "PUT" = "POST") {
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse((res) => res.url().includes(urlIncludes) && res.request().method() === method),
|
||||||
|
trigger.click(),
|
||||||
|
])
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node", "@playwright/test"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
@@ -520,6 +520,7 @@ that role sees the sidebar entries — normal onboarding, not a bug.
|
|||||||
spread across the available width instead of stretching a single narrow column. Modals
|
spread across the available width instead of stretching a single narrow column. Modals
|
||||||
(`ChequePageDialog`/`ReceivedChequeDialog`) were deliberately left at their existing fixed width —
|
(`ChequePageDialog`/`ReceivedChequeDialog`) were deliberately left at their existing fixed width —
|
||||||
a dialog is supposed to stay narrow, this complaint was about full-page create forms only.
|
a dialog is supposed to stay narrow, this complaint was about full-page create forms only.
|
||||||
|
- [x] **Cheque Management status/type fields were displaying raw integers, not names (2026-08-05, user-reported + confirmed live) — fixed by mapping GL's response integers to this frontend's string enums at the API boundary.** User supplied GL's own `06_Enums_Reference.md`: GL has no global `JsonStringEnumConverter`, so while a JSON-**body** enum field is independently declared `string` server-side (and a query-string enum filter binds natively by name — both already correct here, unaffected), a real enum-typed **response** DTO property serializes as its raw underlying integer with no converter to turn it back into a name. `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are exactly that — genuine DB-backed enum properties on GL's response DTOs — so every one of them was arriving as `1`/`2`/`3`/... instead of `"Active"`/`"Issued"`/`"Supplier"`, silently breaking every `===` comparison this frontend does against its own string enums (list badges, the create-form's own `<Select>` values, and both dialogs' status-based available-actions logic), not just the visible label. Added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (`CHEQUE_BOOK_STATUS_BY_CODE`/`CHEQUE_PAGE_ISSUE_STATUS_BY_CODE`/`PAYEE_TYPE_BY_CODE`/`RECEIVED_FROM_TYPE_BY_CODE`/`RECEIVED_CHEQUE_STATUS_BY_CODE`), keyed by the exact integer values `06_Enums_Reference.md` documents. Applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing what GL's JSON for these fields actually is: `number`/`number | null`) and `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers, wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one of these shapes — the translation happens once, at the API boundary, so every existing page/dialog/badge map keeps comparing against the same string enum values as before and needed zero changes itself. Checked every other enum in `06_Enums_Reference.md`'s "Persisted enums" table (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) against this frontend — none are consumed anywhere (no Journal Entry/Fiscal Period/Tax Code/Fixed Asset/Audit Log UI exists here), so Cheque Management is the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||||
- [ ] **Not done — internal ERPCore→GL wiring.** Unrelated to this revision, still deferred
|
- [ ] **Not done — internal ERPCore→GL wiring.** Unrelated to this revision, still deferred
|
||||||
(`docs/12` §6).
|
(`docs/12` §6).
|
||||||
- [ ] **Deferred — bank/cash account edit.** Needs GL service changes first (§4); now needs them for
|
- [ ] **Deferred — bank/cash account edit.** Needs GL service changes first (§4); now needs them for
|
||||||
|
|||||||
Reference in New Issue
Block a user