Compare commits

..

19 Commits

Author SHA1 Message Date
Sasanka 1f10f233dc fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:40:31 +05:30
ImanThiyanga f140959b43 Merge pull request 'feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments' (#32) from test/rebase into Dev
Reviewed-on: #32
2026-08-05 08:04:42 +00:00
ImanThiyanga c31e23c2b9 feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:26:41 +05:30
ImanThiyanga 8e24ed6375 Merge branch 'production' into Dev 2026-08-05 07:22:25 +00:00
ImanThiyanga 9f22026784 Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev
Reviewed-on: #30
2026-08-05 07:22:12 +00:00
ImanThiyanga ef105302bd Merge pull request 'fix: map GL response integers to string enums for Cheque Management fields' (#29) from fix/general-ledger-v01 into production
Reviewed-on: #29
2026-08-05 07:20:28 +00:00
ImanThiyanga d7ee83828c Merge branch 'production' into fix/general-ledger-v01 2026-08-05 07:20:23 +00:00
HarithaRandunu eb7b2691df fix: map GL response integers to string enums for Cheque Management fields 2026-08-05 12:45:34 +05:30
ImanThiyanga 1a0fb4603e Merge pull request 'Dev' (#28) from Dev into production
Reviewed-on: #28
2026-08-05 06:59:22 +00:00
ImanThiyanga 5fc5ef59ac Merge pull request 'feat: enhance purchase orders management with delete and approve actions' (#27) from fixers-frontend-8/5 into Dev
Reviewed-on: #27
2026-08-05 06:58:46 +00:00
Sasanka d1fe164ea2 fix: resolve import conflict in purchase order detail page 2026-08-05 12:21:01 +05:30
Sasanka 02f47bd485 Merge branch 'fixers-frontend-8/5' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into fixers-frontend-8/5 2026-08-05 12:15:28 +05:30
Sasanka 45554ceb9a feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 12:14:08 +05:30
Sasanka 5d0ea3f035 feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 10:56:05 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
Kalana B. Thilakarathna a414dfc4ea graphify update 2026-07-31 12:01:42 +05:30
Kalana B. Thilakarathna 3cccaf4c63 graphify added 2026-07-31 11:53:20 +05:30
ImanThiyanga b7bd8dca5c Merge pull request 'Dev' (#20) from Dev into production
Reviewed-on: #20
2026-07-31 05:24:39 +00:00
58 changed files with 3777 additions and 7747 deletions
+6
View File
@@ -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
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");
}
}
@@ -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");
}
}
@@ -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");
@@ -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");
@@ -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");
+1 -1
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ERPCore",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+2
View File
@@ -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>
<Select<number | null> <div className="flex items-center gap-2">
value={vendorId} <Select<number | null>
onValueChange={setVendorId} value={vendorId}
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))} onValueChange={setVendorId}
> items={(vendors ?? []).map((v) => ({ label: `${v.code}${v.name}`, value: v.vendorId }))}
<SelectTrigger className="h-12! w-full text-base"> >
<SelectValue placeholder="Select vendor code" /> <SelectTrigger className="h-12! w-full text-base">
</SelectTrigger> <SelectValue placeholder="Select vendor" />
<SelectContent> </SelectTrigger>
{(vendors ?? []).map((v) => ( <SelectContent>
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base"> {(vendors ?? []).map((v) => (
{v.code} <SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
</SelectItem> {v.code} {v.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</div> </Select>
<div className="flex flex-col gap-2">
<Label className="text-base">Vendor name</Label> <Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
<Select<number | null> <DialogTrigger
value={vendorId} render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
onValueChange={setVendorId} >
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))} <Plus className="size-5" />
> </DialogTrigger>
<SelectTrigger className="h-12! w-full text-base"> <DialogContent className="sm:max-w-md">
<SelectValue placeholder="Select vendor name" /> <DialogHeader className="items-center text-center">
</SelectTrigger> <DialogTitle>New vendor</DialogTitle>
<SelectContent> <DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
{(vendors ?? []).map((v) => ( </DialogHeader>
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base"> <FieldGroup>
{v.name} <Field data-invalid={!!vErrors.name}>
</SelectItem> <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} />
</SelectContent> <FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
</Select> </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>
{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>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <div className="flex items-center gap-2">
<Plus className="size-5" /> <Link
Add line href="/dashboard/products/new"
</Button> 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()])}>
<Plus className="size-5" />
Add line
</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,24 +164,76 @@ 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) => {
<TableRow key={po.poId}> const editable = isPoEditable(po.status)
<TableCell className="px-3 py-3.5"> return (
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <TableRow key={po.poId}>
{po.docNo} <TableCell className="px-3 py-3.5">
</Link> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
</TableCell> {po.docNo}
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell> </Link>
<TableCell className="px-3 py-3.5"> </TableCell>
<PoStatusBadge status={po.status} /> <TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
</TableCell> <TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell> <PoStatusBadge status={po.status} />
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell> </TableCell>
</TableRow> <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">
<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>
)
})}
</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,265 +176,264 @@ 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>
<FieldLabel>Name</FieldLabel> <FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" /> <Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field> </Field>
<Field> <Field>
<FieldLabel>Role label</FieldLabel> <FieldLabel>Role label</FieldLabel>
<Input <Input
value={data.roleLabel} value={data.roleLabel}
disabled={readOnly} disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })} onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA" placeholder="e.g. QA"
list="role-suggestions" list="role-suggestions"
/> />
<datalist id="role-suggestions"> <datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => ( {ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} /> <option key={r} value={r} />
))} ))}
</datalist> </datalist>
</Field> </Field>
<Field> <Field>
<FieldLabel>Estimated minutes</FieldLabel> <FieldLabel>Estimated minutes</FieldLabel>
<Input <Input
type="number" type="number"
min={0} min={0}
value={data.estimatedMinutes} value={data.estimatedMinutes}
disabled={readOnly} disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })} onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/> />
</Field> </Field>
{/* Inputs */} {/* Inputs */}
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p> <p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && ( {!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}> <Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" /> <Plus className="size-4" />
Add Add
</Button> </Button>
)} )}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>} {data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
{data.inputs.map((input) => ( {data.inputs.map((input) => (
<div key={input.localId} className="rounded-lg border border-border p-2.5"> <div key={input.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<Select<StageInputSource> <Select<StageInputSource>
value={input.source} value={input.source}
onValueChange={(v) => v && changeInputSource(input.localId, v)} onValueChange={(v) => v && changeInputSource(input.localId, v)}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
</SelectContent>
</Select>
{!readOnly && (
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" />
</button>
)}
</div>
<div className="flex flex-col gap-2">
{input.source === "Stock" ? (
<Select<number>
value={input.itemId}
onValueChange={(v) => v && pickInputItem(input, v)}
> >
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick an item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select<string>
value={input.fromOutputKey}
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
</SelectTrigger>
<SelectContent>
{upstreamOptions.map((o) => (
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
{o.stageName} {o.outputName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
/>
</div>
</div>
))}
</div>
</div>
{/* Outputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
{data.outputs.map((output) => (
<div key={output.key} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
{isTerminal ? (
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}> <SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick the finished-good item" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{items.map((i) => ( <SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem key={i.itemId} value={i.itemId} className="text-sm"> <SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( {!readOnly && (
<Input <button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
value={output.name} <Trash2 className="size-4" />
disabled={readOnly} </button>
onChange={(e) => updateOutput(output.key, { name: e.target.value })} )}
placeholder="Output name (work in progress)" </div>
className="h-8 flex-1 text-sm"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
/>
</div>
))}
</div>
</div>
{/* Custom fields */} <div className="flex flex-col gap-2">
<div> {input.source === "Stock" ? (
<div className="mb-2 flex items-center justify-between"> <Select<number>
<p className="text-sm font-semibold text-foreground">Custom fields</p> value={input.itemId}
{!readOnly && ( onValueChange={(v) => v && pickInputItem(input, v)}
<Button type="button" variant="ghost" size="sm" onClick={addField}> >
<Plus className="size-4" /> <SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
Add <SelectValue placeholder="Pick an item" />
</Button> </SelectTrigger>
)} <SelectContent>
</div> {items.map((i) => (
<div className="flex flex-col gap-2"> <SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>} {i.name} · {i.sku}
{data.fieldDefs.map((field) => ( </SelectItem>
<div key={field.localId} className="rounded-lg border border-border p-2.5"> ))}
<div className="mb-2 flex items-center gap-2"> </SelectContent>
<Input </Select>
value={field.label} ) : (
disabled={readOnly} <Select<string>
onChange={(e) => updateField(field.localId, { label: e.target.value })} value={input.fromOutputKey}
placeholder="Label" onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
className="h-8 flex-1 text-sm" >
/> <SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
{!readOnly && ( <SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field"> </SelectTrigger>
<Trash2 className="size-4" /> <SelectContent>
</button> {upstreamOptions.map((o) => (
)} <SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
</div> {o.stageName} {o.outputName}
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>} </SelectItem>
<div className="flex items-center gap-2"> ))}
<Select<CustomFieldType> </SelectContent>
value={field.type} </Select>
onValueChange={(v) => v && updateField(field.localId, { type: v })} )}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}> <QtyRow
<SelectValue /> qty={input.qtyPerBatch}
</SelectTrigger> uomId={input.uomId}
<SelectContent> uoms={uoms}
{FIELD_TYPES.map((t) => ( readOnly={readOnly}
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem> onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
))} onUomChange={(uomId) => updateInput(input.localId, { uomId })}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/> />
<span className="text-xs text-muted-foreground">Required</span>
</div> </div>
</div> </div>
{field.type === "Select" && ( ))}
<Input </div>
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
)}
</div>
))}
</div> </div>
</div>
{!readOnly && ( {/* Outputs */}
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}> <div>
<Trash2 className="size-4" /> <div className="mb-2 flex items-center justify-between">
Delete stage <p className="text-sm font-semibold text-foreground">
</Button> Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
)} </p>
</div> {!readOnly && (
</div> <Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
{data.outputs.map((output) => (
<div key={output.key} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
{isTerminal ? (
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick the finished-good item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={output.name}
disabled={readOnly}
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
placeholder="Output name (work in progress)"
className="h-8 flex-1 text-sm"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
/>
</div>
))}
</div>
</div>
{/* Custom fields */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Custom fields</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addField}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
{data.fieldDefs.map((field) => (
<div key={field.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
<Input
value={field.label}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { label: e.target.value })}
placeholder="Label"
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" />
</button>
)}
</div>
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
<div className="flex items-center gap-2">
<Select<CustomFieldType>
value={field.type}
onValueChange={(v) => v && updateField(field.localId, { type: v })}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/>
<span className="text-xs text-muted-foreground">Required</span>
</div>
</div>
{field.type === "Select" && (
<Input
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
)}
</div>
))}
</div>
</div>
{!readOnly && (
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
<Trash2 className="size-4" />
Delete stage
</Button>
)}
</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,46 +765,52 @@ 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} edges={edges}
edges={edges} nodeTypes={nodeTypes}
nodeTypes={nodeTypes} onNodesChange={locked ? undefined : onNodesChange}
onNodesChange={locked ? undefined : onNodesChange} onEdgesChange={locked ? undefined : onEdgesChange}
onEdgesChange={locked ? undefined : onEdgesChange} onNodesDelete={locked ? undefined : onNodesDelete}
onNodesDelete={locked ? undefined : onNodesDelete} onConnect={locked ? undefined : onConnect}
onConnect={locked ? undefined : onConnect} onNodeClick={onNodeClick}
onNodeClick={onNodeClick} onPaneClick={onPaneClick}
onPaneClick={onPaneClick} nodesDraggable={!locked}
nodesDraggable={!locked} nodesConnectable={!locked}
nodesConnectable={!locked} elementsSelectable
elementsSelectable colorMode={resolvedTheme === "dark" ? "dark" : "light"}
colorMode={resolvedTheme === "dark" ? "dark" : "light"} fitView
fitView >
> <Background />
<Background /> <Controls showInteractive={!locked} />
<Controls showInteractive={!locked} /> <MiniMap pannable zoomable />
<MiniMap pannable zoomable /> </ReactFlow>
</ReactFlow>
)}
</div>
{selectedNode && (
<StageEditorPanel
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)} )}
</div> </div>
{selectedNode && (
<StageEditorPanel
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)}
</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}
@@ -440,220 +440,220 @@ export default function NewGrnPage() {
{!poLoading && lines.length > 0 && ( {!poLoading && lines.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<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>
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead> <TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead> <TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead> <TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" /> <TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{lines.map((line) => { {lines.map((line) => {
const item = itemFor(line.itemId) const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {} const errors = lineErrors[line.key] ?? {}
return ( return (
<TableRow key={line.key}> <TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
{line.poLineId ? ( {line.poLineId ? (
<div className="flex h-11 items-center text-base"> <div className="flex h-11 items-center text-base">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`} {item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
</div>
) : (
<>
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">
PO price {line.poUnitPrice.toFixed(2)} variance recorded
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
{(() => {
const c = computeLine(line)
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
</span>
</div> </div>
) ) : (
})()} <>
</TableCell> <Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<TableCell className="px-3 py-3 align-top"> <SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<Select<HoldStatus> <SelectValue placeholder="Select item" />
value={line.holdStatus} </SelectTrigger>
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })} <SelectContent>
> {(items ?? []).map((i) => (
<SelectTrigger className="h-11! w-full text-base"> <SelectItem key={i.itemId} value={i.itemId} className="text-base">
<SelectValue /> {i.sku} {i.name}
</SelectTrigger> </SelectItem>
<SelectContent> ))}
<SelectItem value="Available" className="text-base">Available</SelectItem> </SelectContent>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem> </Select>
</SelectContent> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</Select> </>
</TableCell> )}
<TableCell className="px-3 py-3 align-top"> </TableCell>
{item?.trackingMode === "Batch" && ( <TableCell className="px-3 py-3 align-top">
<div className="flex flex-col gap-1.5"> {line.poLineId ? (
<Input <div className="flex h-11 items-center text-base">
placeholder="Batch no." {uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
value={line.batchNo} </div>
aria-invalid={!!errors.batchNo} ) : (
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })} <>
className="h-9 text-sm" <Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
/> <SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<Input <SelectValue placeholder="UOM" />
type="date" </SelectTrigger>
value={line.expiryDate} <SelectContent>
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })} {(uoms ?? []).map((u) => (
className="h-9 text-sm" <SelectItem key={u.uomId} value={u.uomId} className="text-base">
/> {u.name}
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} /> </SelectItem>
</div> ))}
)} </SelectContent>
{item?.trackingMode === "Serial" && ( </Select>
<div className="flex flex-col gap-1.5"> <FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
<textarea </>
placeholder="One serial per line" )}
value={line.serialNumbersText} </TableCell>
aria-invalid={!!errors.serialNumbers} <TableCell className="px-3 py-3 align-top">
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })} <Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" <SelectTrigger className="h-11! w-full text-base">
/> <SelectValue placeholder="None" />
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} /> </SelectTrigger>
</div> <SelectContent>
)} {bins.map((b) => (
{(!item || item.trackingMode === "None") && ( <SelectItem key={b.binId} value={b.binId} className="text-base">
<span className="text-sm text-muted-foreground"></span> {b.code}
)} </SelectItem>
</TableCell> ))}
<TableCell className="px-3 py-3 align-top"> </SelectContent>
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> </Select>
<Trash2 className="size-5" /> </TableCell>
</Button> <TableCell className="px-3 py-3 align-top">
</TableCell> <Input
</TableRow> type="number"
) min="0"
})} step="any"
</TableBody> value={line.qty}
</Table> aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">
PO price {line.poUnitPrice.toFixed(2)} variance recorded
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
{(() => {
const c = computeLine(line)
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
</span>
</div>
)
})()}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus>
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div> </div>
)} )}
{!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>
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to Settings
</Link>
</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" }))}>
Cancel
</Link>
<Button size="lg" onClick={save} disabled={saving}>
{saving ? "Saving..." : "Save Profile"}
</Button>
</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> </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
View File
@@ -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>
} }
+74 -16
View File
@@ -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)
}, },
} }
+18
View File
@@ -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
+14
View File
@@ -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
+155
View File
@@ -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.
+125
View File
@@ -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"
}
}
}
+19
View File
@@ -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"
}
}
+120
View File
@@ -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()
}
}
+23
View File
@@ -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()
}
}
+135
View File
@@ -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")
}
}
+82
View File
@@ -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()
}
}
+35
View File
@@ -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"],
},
],
})
+29
View File
@@ -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()
})
})
+112
View File
@@ -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)
})
})
+16
View File
@@ -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 })
})
+125
View File
@@ -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()
})
})
+119
View File
@@ -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)
})
})
+90
View File
@@ -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()
})
})
+209
View File
@@ -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}`)
}
}
+23
View File
@@ -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")
+113
View File
@@ -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
}
+14
View File
@@ -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"]
}
+1
View File
@@ -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