Compare commits

...

30 Commits

Author SHA1 Message Date
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
DeepnaPooja cbc72ef830 fix sales issues 2026-08-05 17:03:49 +05:30
ImanThiyanga 4324ba1a96 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-08-05 16:16:50 +05:30
ImanThiyanga 1af16d3dec fix: update DefaultConnection string for development environment 2026-08-05 16:16:15 +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
DeepnaPooja 0750773f94 centralize sales flow 2026-08-04 17:19:30 +05:30
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
DeepnaPooja 0d60aeef64 Merge pull request 'Enhance/sales' (#25) from enhance/sales into Dev
Reviewed-on: #25
2026-08-04 06:06:31 +00:00
DeepnaPooja c6bc8065a2 DB issue fix 2026-08-04 11:27:39 +05:30
DeepnaPooja 4f56d481a2 Add bundle sales front end integration 2026-08-04 11:18:32 +05:30
DeepnaPooja d79371697e Add bundle sales backend implementation 2026-08-04 11:17:21 +05:30
ImanThiyanga f7a65b5f7e fixed dbContext 2026-08-03 12:18:44 +05:30
ashan_rusiru 6b216195c6 Merge pull request 'Feat/sales management' (#24) from feat/sales-management into Dev
Reviewed-on: #24
2026-08-02 04:56:51 +00:00
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
115 changed files with 8777 additions and 23305 deletions
+6
View File
@@ -30,6 +30,12 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Migrations ─────────────────────────────────────────────────────────
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
@@ -0,0 +1,80 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
[FromQuery] PageQuery query,
CancellationToken ct)
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
[HttpGet("templates/{bundleSaleTemplateId:int}")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
{
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
{
var result = await _bundles.GetAsync(bundleSaleId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{bundleSaleId:int}/posting-check")]
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
[HttpPost]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
{
var result = await _bundles.CreateAsync(request, ct);
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
}
[HttpPut("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
{
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
}
[HttpPost("{bundleSaleId:int}/post")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
[HttpPost("{bundleSaleId:int}/cancel")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
}
+1
View File
@@ -19,4 +19,5 @@ public static class DocumentTypes
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSale
{
public int BundleSaleId { get; set; }
public string BundleNo { get; set; } = string.Empty;
public DateTime BundleDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public string BundleName { get; set; } = string.Empty;
public string BundleCode { get; set; } = string.Empty;
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
public decimal ComponentSubtotal { get; set; }
public decimal BundlePrice { get; set; }
public decimal MarginAmount { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleLine
{
public int BundleSaleLineId { get; set; }
public int BundleSaleId { get; set; }
public BundleSale? BundleSale { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public bool IncludeInBundle { get; set; } = true;
public bool IsComponent { get; set; } = true;
public int? ParentLineId { get; set; }
}
@@ -0,0 +1,17 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplate
{
public int BundleSaleTemplateId { get; set; }
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplateLine
{
public int BundleSaleTemplateLineId { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public bool IncludeInBundle { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum BundleSaleStatus
{
Draft = 0,
Posted = 1,
Cancelled = 2
}
@@ -0,0 +1,83 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record BundleSaleLineDto(
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
public sealed record BundleSaleDto(
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
IReadOnlyList<BundleSaleLineDto> Lines);
public sealed record BundleSaleSummaryDto(
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
int WarehouseId, string BundleName, string BundleCode, BundleSaleStatus Status,
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
public sealed record BundleSaleTemplateLineDto(
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto(
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
public sealed record BundleSalePostingCheckDto(
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
IReadOnlyList<BundleSalePostingIssueDto> Issues);
public sealed class CreateBundleSaleTemplateLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
public bool IncludeInBundle { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
public sealed class CreateBundleSaleRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required] public int BundleSaleTemplateId { get; set; }
[Required] public string BundleName { get; set; } = string.Empty;
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
public sealed class UpdateBundleSaleRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required] public int CashierUserId { get; set; }
[Required] public int BundleSaleTemplateId { get; set; }
[Required] public string BundleName { get; set; } = string.Empty;
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSale>
{
public void Configure(EntityTypeBuilder<BundleSale> builder)
{
builder.ToTable("bundle_sales");
builder.HasKey(x => x.BundleSaleId);
builder.Property(x => x.BundleNo).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.BundleNo).IsUnique();
builder.Property(x => x.BundleDate).IsRequired();
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.BundleName).IsRequired().HasMaxLength(200);
builder.Property(x => x.BundleCode).IsRequired().HasMaxLength(50);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(BundleSaleStatus.Draft);
builder.Property(x => x.ComponentSubtotal).HasPrecision(18, 4);
builder.Property(x => x.BundlePrice).HasPrecision(18, 4);
builder.Property(x => x.MarginAmount).HasPrecision(18, 4);
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.BundleSaleTemplate).WithMany().HasForeignKey(x => x.BundleSaleTemplateId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSale)
.HasForeignKey(x => x.BundleSaleId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<BundleSaleLine>
{
public void Configure(EntityTypeBuilder<BundleSaleLine> builder)
{
builder.ToTable("bundle_sale_lines");
builder.HasKey(x => x.BundleSaleLineId);
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
builder.Property(x => x.IsComponent).HasDefaultValue(true);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,34 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<BundleSaleTemplate>
{
public void Configure(EntityTypeBuilder<BundleSaleTemplate> builder)
{
builder.ToTable("bundle_sale_templates");
builder.HasKey(x => x.BundleSaleTemplateId);
builder.Property(x => x.TemplateCode).IsRequired().HasMaxLength(50);
builder.HasIndex(x => x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSaleTemplate)
.HasForeignKey(x => x.BundleSaleTemplateId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfiguration<BundleSaleTemplateLine>
{
public void Configure(EntityTypeBuilder<BundleSaleTemplateLine> builder)
{
builder.ToTable("bundle_sale_template_lines");
builder.HasKey(x => x.BundleSaleTemplateLineId);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.SortOrder).HasDefaultValue(0);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -38,7 +38,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
);
}
}
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
+240 -27
View File
@@ -43,7 +43,7 @@ public static class DataSeeder
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
dirty |= await SeedCompanyProfileAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -109,31 +109,31 @@ public static class DataSeeder
/// Seeds a printable company profile with reasonable defaults for invoice headers.
/// These values are intentionally editable later through the API.
/// </summary>
private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
//{
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
db.CompanyProfiles.Add(new CompanyProfile
{
CompanyProfileId = CompanyProfile.SingletonId,
LegalName = "ERP Core Trading (Pvt) Ltd",
TradeName = "ERP Core Trading",
TaxRegistrationNo = "TAX-DEFAULT-001",
VatRegistrationNo = "VAT-DEFAULT-001",
AddressLine1 = "1 Demo Street",
City = "Colombo",
Country = "Sri Lanka",
Phone = "+94 11 000 0000",
Email = "accounts@example.com",
BankName = "Demo Bank",
BankBranch = "Colombo Main",
AccountName = "ERP Core Trading (Pvt) Ltd",
AccountNumber = "000123456789",
SwiftCode = "DEMO1234",
FooterNote = "Thank you for your business."
});
return true;
}
// db.CompanyProfiles.Add(new CompanyProfile
// {
// CompanyProfileId = CompanyProfile.SingletonId,
// LegalName = "ERP Core Trading (Pvt) Ltd",
// TradeName = "ERP Core Trading",
// TaxRegistrationNo = "TAX-DEFAULT-001",
// VatRegistrationNo = "VAT-DEFAULT-001",
// AddressLine1 = "1 Demo Street",
// City = "Colombo",
// Country = "Sri Lanka",
// Phone = "+94 11 000 0000",
// Email = "accounts@example.com",
// BankName = "Demo Bank",
// BankBranch = "Colombo Main",
// AccountName = "ERP Core Trading (Pvt) Ltd",
// AccountNumber = "000123456789",
// SwiftCode = "DEMO1234",
// FooterNote = "Thank you for your business."
// });
// return true;
//}
/// <summary>
/// Seeds the minimum catalog data required for the sales demo rows to exist.
@@ -318,6 +318,15 @@ public static class DataSeeder
dirty |= await SeedSalesSequencesAsync(db, ct);
dirty |= await SeedSampleSalesDocsAsync(db, ct);
try
{
dirty |= await SeedBundleSalesAsync(db, ct);
}
catch
{
// Bundle demo data is best-effort only; never block startup because of seed drift.
}
return dirty;
}
@@ -369,7 +378,7 @@ public static class DataSeeder
{
var year = DateTime.UtcNow.Year;
var existing = await db.NumberSequences
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip))
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale))
.Select(s => s.DocType)
.ToListAsync(ct);
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -377,7 +386,8 @@ public static class DataSeeder
var seeds = new[]
{
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 }
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 }
};
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
@@ -387,6 +397,209 @@ public static class DataSeeder
return true;
}
private static async Task<bool> SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct))
return false;
var customer = await db.Customers.AsNoTracking()
.OrderBy(c => c.CustomerId)
.FirstOrDefaultAsync(ct);
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
.OrderByDescending(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var items = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.Take(2)
.ToListAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
var user = await db.Users.AsNoTracking()
.OrderBy(u => u.UserId)
.FirstOrDefaultAsync(ct);
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
return false;
var now = DateTime.UtcNow;
var template = new BundleSaleTemplate
{
TemplateCode = "BND-DEMO-001",
TemplateName = "Demo Bundle Pack",
Description = "Seeded fixed bundle template for integration testing",
Status = EntityStatus.Active,
CreatedAt = now,
Lines =
[
new BundleSaleTemplateLine
{
ItemId = items[0].ItemId,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
]
};
db.BundleSaleTemplates.Add(template);
await db.SaveChangesAsync(ct);
var bundleSales = new[]
{
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00001",
BundleDate = now.Date.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = 0m,
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00002",
BundleDate = now.Date.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00003",
BundleDate = now.Date,
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = secondaryWarehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
}
};
db.BundleSales.AddRange(bundleSales);
return true;
}
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
@@ -36,8 +36,7 @@ public class ErpDbContext : DbContext
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
/// <summary>Singleton business profile used by printable documents.</summary>
public DbSet<CompanyProfile> CompanyProfiles => Set<CompanyProfile>();
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
@@ -91,6 +90,10 @@ public class ErpDbContext : DbContext
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
// --- Reference data (docs/10 Part C.7) ---
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
// <auto-generated />
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260801000000_AddCompanyProfile")]
partial class AddCompanyProfile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
// This designer is kept minimal because the repo does not regenerate migrations here.
// EF tooling only needs the migration metadata to exist; the active model snapshot
// is updated separately.
}
}
}
@@ -1,60 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCompanyProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "company_profile",
columns: table => new
{
CompanyProfileId = table.Column<int>(type: "integer", nullable: false),
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TradeName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
TaxRegistrationNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
VatRegistrationNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Country = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Phone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
BankName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
BankBranch = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AccountNumber = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
SwiftCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
FooterNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_company_profile", x => x.CompanyProfileId);
table.CheckConstraint("ck_company_profile_singleton", "\"CompanyProfileId\" = 1");
table.ForeignKey(
name: "FK_company_profile_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "company_profile");
}
}
}
@@ -9,11 +9,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260801025920_InitialCreate")]
partial class InitialCreate
[Migration("20260804111315_a")]
partial class a
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -368,6 +368,269 @@ namespace ERPCore.Infra.Persistence.Migrations
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 =>
{
b.Property<int>("CategoryId")
@@ -1953,6 +2216,15 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Accounts",
SortOrder = 12,
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"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -5152,6 +5434,111 @@ namespace ERPCore.Infra.Persistence.Migrations
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 =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
@@ -6558,6 +6945,16 @@ namespace ERPCore.Infra.Persistence.Migrations
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 =>
{
b.Navigation("SubCategories");
@@ -6,10 +6,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
public partial class a : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -31,6 +31,25 @@ namespace ERPCore.Infra.Persistence.Migrations
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(
name: "categories",
columns: table => new
@@ -903,6 +922,61 @@ namespace ERPCore.Infra.Persistence.Migrations
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(
name: "sales_invoices",
columns: table => new
@@ -1020,6 +1094,50 @@ namespace ERPCore.Infra.Persistence.Migrations
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(
name: "item_reorders",
columns: table => new
@@ -1332,6 +1450,53 @@ namespace ERPCore.Infra.Persistence.Migrations
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(
name: "sales_invoice_lines",
columns: table => new
@@ -2782,7 +2947,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 },
{ 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(
@@ -2835,7 +3001,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 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 },
{ 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(
@@ -2905,6 +3072,78 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "brands",
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(
name: "IX_categories_Name",
table: "categories",
@@ -4234,6 +4473,12 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "audit_logs");
migrationBuilder.DropTable(
name: "bundle_sale_lines");
migrationBuilder.DropTable(
name: "bundle_sale_template_lines");
migrationBuilder.DropTable(
name: "hr_attendance_records");
@@ -4336,6 +4581,9 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "vendor_quotation_lines");
migrationBuilder.DropTable(
name: "bundle_sales");
migrationBuilder.DropTable(
name: "hr_attendance_upload_batches");
@@ -4393,6 +4641,9 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "vendor_quotations");
migrationBuilder.DropTable(
name: "bundle_sale_templates");
migrationBuilder.DropTable(
name: "hr_payroll_runs");
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -16,7 +16,9 @@ using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.OpenApi;
using Npgsql;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -32,7 +34,10 @@ builder.Services.AddControllers()
// EF Core + PostgreSQL
builder.Services.AddDbContext<ErpDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
{
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
o.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
});
// ProblemDetails (RFC 7807) + domain-exception mapping
builder.Services.AddProblemDetails();
@@ -78,7 +83,7 @@ builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
@@ -101,8 +106,13 @@ builder.Services.AddScoped<IGrnService, GrnService>();
// Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
@@ -180,6 +190,7 @@ var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
// await EnsureMigrationBaselineAsync(db);
await db.Database.MigrateAsync();
try
{
@@ -205,3 +216,4 @@ app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,256 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class BundleSaleService : IBundleSaleService
{
private readonly IRepository<BundleSaleTemplate> _templates;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ISalesPostingService _posting;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public BundleSaleService(
IRepository<BundleSale> bundles,
IRepository<BundleSaleTemplate> templates,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Uom> uoms,
IRepository<Warehouse> warehouses,
IRepository<User> users,
ISalesDomainService sales,
IUomConverter uomConverter,
ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers,
IUnitOfWork uow)
{
_templates = templates;
_bundles = bundles;
_customers = customers;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_sales = sales;
_uomConverter = uomConverter;
_posting = posting;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
}
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
{
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
{
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
return template is null ? null : new BundleSaleTemplateDto(
template.BundleSaleTemplateId,
template.TemplateCode,
template.TemplateName,
template.Description,
template.Status,
template.CreatedAt,
template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
return bundle is null ? null : Map(bundle);
}
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
=> _posting.CheckBundleAsync(bundleSaleId, ct);
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
var bundle = new BundleSale
{
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
BundleDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
CashierUserId = request.CashierUserId,
BundleSaleTemplateId = request.BundleSaleTemplateId,
BundleName = request.BundleName,
BundleCode = string.Empty,
Status = BundleSaleStatus.Draft,
CreatedAt = DateTime.UtcNow
};
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
Recalculate(bundle, request.BundlePrice);
bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct);
bundle.ConcurrencyStamp = 1;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
bundle.CustomerId = request.CustomerId;
bundle.WarehouseId = request.WarehouseId;
bundle.CashierUserId = request.CashierUserId;
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
bundle.BundleName = request.BundleName;
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice);
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
{
await _posting.PostBundleAsync(bundleSaleId, ct);
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
return Map(bundle);
}
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
bundle.Status = BundleSaleStatus.Cancelled;
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
private async Task<List<BundleSaleLine>> BuildLinesAsync(
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
{
var lines = new List<BundleSaleLine>();
var sourceLines = requestLines.Count > 0
? requestLines.OrderBy(x => x.SortOrder).ToList()
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
{
ItemId = x.ItemId,
UomId = x.UomId,
WarehouseId = x.WarehouseId,
Qty = x.Qty,
UnitPrice = x.UnitPrice,
IncludeInBundle = x.IncludeInBundle,
SortOrder = x.SortOrder
}).ToList();
foreach (var r in sourceLines)
{
if (r.Qty <= 0)
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
// Keep any per-line warehouse input from drifting away from the header.
var lineWarehouseId = warehouseId;
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = qtyBase,
UomId = item.BaseUomId,
WarehouseId = lineWarehouseId,
UnitPrice = unitCostBase,
LineTotal = calc.LineTotal,
IncludeInBundle = r.IncludeInBundle,
IsComponent = true,
ParentLineId = null
});
}
return lines;
}
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
{
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
bundle.BundlePrice = bundlePrice;
bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal;
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
bundle.TaxTotal = 0m;
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
}
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
private static BundleSaleDto Map(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
}
@@ -0,0 +1,18 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface IBundleSaleService
{
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
}
@@ -1,10 +0,0 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
public interface ICompanyProfileService
{
Task<ETagged<CompanyProfileDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<CompanyProfileDto>> UpdateAsync(UpdateCompanyProfileRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
public interface ISalesDocumentWorkflowService
{
Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default);
Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,51 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface ISalesDomainService
{
Task ValidateSalesHeaderAsync(
int customerId,
int warehouseId,
int? cashierUserId,
bool requireCashierUser,
CancellationToken ct = default);
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
int lineUomId,
int lineWarehouseId,
decimal qty,
decimal freeQty,
int? parentLineId,
CancellationToken ct = default);
Task<SalesPriceResolution> ResolveLinePriceAsync(
int itemId,
int warehouseId,
decimal? requestedUnitPrice,
bool allowManualOverride,
CancellationToken ct = default);
SalesLineComputation ComputeLine(
decimal qty,
decimal freeQty,
decimal unitPrice,
SalesDiscountMode discountMode,
decimal discountPct,
decimal discountValue,
decimal discountAmount,
decimal taxPct,
bool isFreeIssue);
Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
}
public sealed record SalesLineComputation(
decimal Gross,
decimal DiscountTotal,
decimal NetUnitPrice,
decimal LineTotal,
decimal TaxAmount);
@@ -0,0 +1,12 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface ISalesMappingService
{
SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice);
SalesSlipTotalsDto MapSlipTotals(SalesSlip slip);
SalesInvoiceDto MapInvoice(SalesInvoice invoice);
SalesSlipDto MapSlip(SalesSlip slip);
}
@@ -0,0 +1,14 @@
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface ISalesPostingService
{
Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default);
Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
Task PostSlipAsync(int salesSlipId, CancellationToken ct = default);
Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default);
}
@@ -0,0 +1,44 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesSlip> _slips;
public SalesDocumentWorkflowService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
{
_invoices = invoices;
_slips = slips;
}
public async Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited.");
return invoice;
}
public async Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default)
{
var slip = await _slips.Query().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412);
if (slip.Status != SalesSlipStatus.Draft)
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited.");
return slip;
}
}
@@ -0,0 +1,110 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesDomainService : ISalesDomainService
{
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly ISalesPricingService _pricing;
public SalesDomainService(
IRepository<Customer> customers,
IRepository<Warehouse> warehouses,
IRepository<User> users,
IRepository<Item> items,
IRepository<Uom> uoms,
ISalesPricingService pricing)
{
_customers = customers;
_warehouses = warehouses;
_users = users;
_items = items;
_uoms = uoms;
_pricing = pricing;
}
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (requireCashierUser)
{
if (cashierUserId is null)
throw new DomainException(ErrorCodes.Validation, "Cashier user is required.", 422);
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
throw new NotFoundException($"User {cashierUserId} was not found.");
}
}
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
if (qty <= 0)
throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422);
if (freeQty < 0)
throw new DomainException(ErrorCodes.Validation, "Sales free quantity cannot be negative.", 422);
if (parentLineId is not null && parentLineId <= 0)
throw new DomainException(ErrorCodes.Validation, "Parent line id must be positive when supplied.", 422);
if (lineWarehouseId != headerWarehouseId)
throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422);
if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct))
throw new NotFoundException($"Item {lineItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct))
throw new NotFoundException($"UOM {lineUomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct))
throw new NotFoundException($"Warehouse {lineWarehouseId} was not found.");
}
public Task<SalesPriceResolution> ResolveLinePriceAsync(int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
=> _pricing.ResolveAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
public SalesLineComputation ComputeLine(
decimal qty,
decimal freeQty,
decimal unitPrice,
SalesDiscountMode discountMode,
decimal discountPct,
decimal discountValue,
decimal discountAmount,
decimal taxPct,
bool isFreeIssue)
{
var gross = qty * unitPrice;
var discountTotal = isFreeIssue
? 0m
: CalculateDiscount(gross, discountMode, discountPct, discountValue, discountAmount);
var netUnit = qty > 0 ? (gross - discountTotal) / qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (taxPct / 100m);
return new SalesLineComputation(gross, discountTotal, netUnit, lineTotal, taxAmount);
}
public async Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default)
=> await _items.Query().AsNoTracking()
.Where(x => x.ItemId == itemId)
.Select(x => x.StockNature == StockNature.Stocked)
.FirstAsync(ct);
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{
var computed = mode == SalesDiscountMode.Amount
? discountValue
: gross * (discountPct / 100m);
if (computed <= 0m && legacyDiscountAmount > 0m)
computed = legacyDiscountAmount;
return Math.Min(gross, Math.Max(0m, computed));
}
}
+36 -127
View File
@@ -21,15 +21,18 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly ISalesPricingService _pricing;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly ISalesPostingService _posting;
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public SalesInvoiceService(
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesPricingService pricing, IFifoCostingService fifo,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
@@ -37,8 +40,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_pricing = pricing;
_fifo = fifo;
_sales = sales;
_posting = posting;
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
@@ -66,47 +71,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
public async Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());
var issues = new List<SalesInvoicePostingIssueDto>();
foreach (var line in invoice.Lines)
{
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesInvoicePostingIssueDto(
line.SalesInvoiceLineId,
line.ItemId,
item.Sku,
item.Name,
line.WarehouseId,
requestedQty,
available,
requestedQty - available,
line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
}
public Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
=> _posting.CheckInvoiceAsync(salesInvoiceId, ct);
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var invoice = new SalesInvoice
{
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
@@ -120,62 +93,39 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
};
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
invoice.Lines = await BuildLinesAsync(request.Lines, ct);
invoice.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
Recalculate(invoice);
await _invoices.AddAsync(invoice, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited.");
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
invoice.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.Lines, ct)) invoice.Lines.Add(line);
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) invoice.Lines.Add(line);
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be posted.");
var posted = await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in invoice.Lines)
{
if (line.Qty <= 0) continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token);
}
invoice.Status = SalesInvoiceStatus.Posted;
invoice.UpdatedAt = DateTime.UtcNow;
return invoice;
}, ct);
return Map(posted);
}
await _posting.PostInvoiceAsync(salesInvoiceId, ct);
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return _mapping.MapInvoice(invoice);
}
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
{
@@ -186,43 +136,21 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
invoice.Status = SalesInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(invoice);
return _mapping.MapInvoice(invoice);
}
private async Task ValidateReferencesAsync(int customerId, int warehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
foreach (var line in lines)
{
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
throw new NotFoundException($"UOM {line.UomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
}
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
var gross = r.Qty * unitPrice;
var discountTotal = r.IsFreeIssue
? 0m
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (r.TaxPct / 100m);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
lines.Add(new SalesInvoiceLine
{
@@ -236,12 +164,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
BaseCost = unitPrice,
PriceSource = priceSource,
DiscountPct = r.DiscountPct,
DiscountAmount = discountTotal,
DiscountAmount = calc.DiscountTotal,
DiscountMode = r.DiscountMode,
NetUnitPrice = netUnit,
LineTotal = lineTotal,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxAmount = taxAmount,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
});
@@ -261,26 +189,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
invoice.BalanceAmount = invoice.NetPayable;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{
var computed = mode == SalesDiscountMode.Amount
? discountValue
: gross * (discountPct / 100m);
if (computed <= 0m && legacyDiscountAmount > 0m)
computed = legacyDiscountAmount;
return Math.Min(gross, Math.Max(0m, computed));
}
private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto(
x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
private static SalesInvoiceDto Map(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo,
x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount),
x.Lines.Select(l => new SalesInvoiceLineDto(l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
}
@@ -0,0 +1,51 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
public sealed class SalesMappingService : ISalesMappingService
{
public SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice)
=> new(
invoice.Subtotal,
invoice.DiscountTotal,
invoice.Lines.Sum(l => l.FreeQty),
invoice.TaxTotal,
invoice.GrandTotal,
invoice.RoundOff,
invoice.NetPayable,
invoice.PaidAmount,
invoice.BalanceAmount);
public SalesSlipTotalsDto MapSlipTotals(SalesSlip slip)
=> new(
slip.Subtotal,
slip.DiscountTotal,
slip.Lines.Sum(l => l.FreeQty),
slip.TaxTotal,
slip.GrandTotal,
slip.PaidAmount,
slip.BalanceAmount);
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new(
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
MapInvoiceTotals(invoice),
invoice.Lines.Select(l => new SalesInvoiceLineDto(
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
public SalesSlipDto MapSlip(SalesSlip slip)
=> new(
slip.SalesSlipId, slip.SlipNo, slip.SlipDate, slip.CustomerId, slip.CustomerSnapshotName,
slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt,
MapSlipTotals(slip),
slip.Lines.Select(l => new SalesSlipLineDto(
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
}
@@ -0,0 +1,218 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesPostingService : ISalesPostingService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Item> _items;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesPostingService(
IRepository<SalesInvoice> invoices,
IRepository<SalesSlip> slips,
IRepository<BundleSale> bundles,
IRepository<Item> items,
IFifoCostingService fifo,
ISalesDomainService sales,
IUomConverter uomConverter,
ICurrentUser currentUser,
IUnitOfWork uow)
{
_invoices = invoices;
_slips = slips;
_bundles = bundles;
_items = items;
_fifo = fifo;
_sales = sales;
_uomConverter = uomConverter;
_currentUser = currentUser;
_uow = uow;
}
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());
var issues = new List<SalesInvoicePostingIssueDto>();
foreach (var line in invoice.Lines)
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesInvoicePostingIssueDto(
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
}
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.Status != SalesSlipStatus.Draft)
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());
var issues = new List<SalesSlipPostingIssueDto>();
foreach (var line in slip.Lines)
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesSlipPostingIssueDto(
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
}
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty<BundleSalePostingIssueDto>());
var issues = new List<BundleSalePostingIssueDto>();
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
var item = await _items.Query().AsNoTracking()
.FirstAsync(x => x.ItemId == line.ItemId, ct);
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= line.Qty) continue;
issues.Add(new BundleSalePostingIssueDto(
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
=> PostAsync(
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
getDocId: x => x.SalesInvoiceId,
ct: ct);
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync(
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
getDocId: x => x.SalesSlipId,
ct: ct);
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
=> PostAsync(
load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct),
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
// Bundle lines are normalized to base UOM on save, so posting should consume the
// stored base quantity directly instead of converting again.
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale,
getDocId: x => x.BundleSaleId,
ct: ct);
private async Task PostAsync<T>(
Func<Task<T?>> load,
string notFoundMessage,
Func<T, object> statusSelector,
Func<T, string> ensureDraftMessage,
Func<T, IEnumerable<PostingLine>> getLines,
Action<T> setPosted,
Action<T> setUpdated,
string sourceDocType,
Func<T, int> getDocId,
CancellationToken ct)
where T : class
{
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
var status = statusSelector(doc);
var statusValue = status?.ToString() ?? string.Empty;
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in getLines(doc))
{
if (line.Qty <= 0) continue;
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
}
setPosted(doc);
setUpdated(doc);
}, ct);
}
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
}
@@ -9,8 +9,6 @@ namespace ERPCore.Services;
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
{
private const decimal FreeIssueThreshold = 10m;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<Item> _items;
@@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
if (slip is null) return null;
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
var freeIssueLines = slip.Lines
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
.ToList();
if (freeIssueLines.Count == 0)
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
var candidateItems = await _items.Query().AsNoTracking()
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
.ToListAsync(ct);
@@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
foreach (var line in freeIssueLines)
{
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
if (freeQty <= 0m) continue;
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
{
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
@@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
item.Sku,
item.Name,
line.Qty,
freeQty,
FreeIssueThreshold,
line.FreeQty,
line.Qty,
rewardOptions));
}
@@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
}
}
}
+37 -126
View File
@@ -22,15 +22,18 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly ISalesPricingService _pricing;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly ISalesPostingService _posting;
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public SalesSlipService(
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesPricingService pricing, IFifoCostingService fifo,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_slips = slips;
@@ -39,8 +42,10 @@ public sealed class SalesSlipService : ISalesSlipService
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_pricing = pricing;
_fifo = fifo;
_sales = sales;
_posting = posting;
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
@@ -67,7 +72,7 @@ public sealed class SalesSlipService : ISalesSlipService
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
@@ -95,44 +100,12 @@ public sealed class SalesSlipService : ISalesSlipService
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
}
public async Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.Status != SalesSlipStatus.Draft)
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());
var issues = new List<SalesSlipPostingIssueDto>();
foreach (var line in slip.Lines)
{
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesSlipPostingIssueDto(
line.SalesSlipLineId,
line.ItemId,
item.Sku,
item.Name,
line.WarehouseId,
requestedQty,
available,
requestedQty - available,
line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
}
public Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
=> _posting.CheckSlipAsync(salesSlipId, ct);
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var slip = new SalesSlip
{
@@ -145,61 +118,38 @@ public sealed class SalesSlipService : ISalesSlipService
CreatedAt = DateTime.UtcNow
};
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines = await BuildLinesAsync(request.Lines, ct);
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
Recalculate(slip);
await _slips.AddAsync(slip, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
public async Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412);
if (slip.Status != SalesSlipStatus.Draft)
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited.");
var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, ct);
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
slip.CustomerId = request.CustomerId;
slip.WarehouseId = request.WarehouseId;
slip.CashierUserId = request.CashierUserId;
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.Lines, ct)) slip.Lines.Add(line);
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
Recalculate(slip);
slip.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.Status != SalesSlipStatus.Draft)
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be posted.");
var posted = await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in slip.Lines)
{
if (line.Qty <= 0 && line.FreeQty <= 0) continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token);
}
slip.Status = SalesSlipStatus.Posted;
slip.UpdatedAt = DateTime.UtcNow;
return slip;
}, ct);
return Map(posted);
}
await _posting.PostSlipAsync(salesSlipId, ct);
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
return _mapping.MapSlip(slip);
}
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
{
@@ -210,45 +160,21 @@ public sealed class SalesSlipService : ISalesSlipService
slip.Status = SalesSlipStatus.Cancelled;
slip.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(slip);
return _mapping.MapSlip(slip);
}
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List<CreateSalesSlipLineRequest> lines, CancellationToken ct)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
throw new NotFoundException($"User {cashierUserId} was not found.");
foreach (var line in lines)
{
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
throw new NotFoundException($"UOM {line.UomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
}
}
private async Task<List<SalesSlipLine>> BuildLinesAsync(List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesSlipLine>();
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
var gross = r.Qty * unitPrice;
var discountTotal = r.IsFreeIssue
? 0m
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (r.TaxPct / 100m);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
lines.Add(new SalesSlipLine
{
@@ -263,11 +189,11 @@ public sealed class SalesSlipService : ISalesSlipService
PriceSource = priceSource,
DiscountMode = r.DiscountMode,
DiscountPct = r.DiscountPct,
DiscountAmount = discountTotal,
NetUnitPrice = netUnit,
LineTotal = lineTotal,
DiscountAmount = calc.DiscountTotal,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxAmount = taxAmount,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
});
@@ -285,21 +211,9 @@ public sealed class SalesSlipService : ISalesSlipService
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{
var computed = mode == SalesDiscountMode.Amount
? discountValue
: gross * (discountPct / 100m);
if (computed <= 0m && legacyDiscountAmount > 0m)
computed = legacyDiscountAmount;
return Math.Min(gross, Math.Max(0m, computed));
}
private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
_mapping.MapSlipTotals(x), x.CreatedAt);
private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x)
{
@@ -352,8 +266,5 @@ public sealed class SalesSlipService : ISalesSlipService
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
}
private static SalesSlipDto Map(SalesSlip x) => new(
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt,
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount),
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
"DefaultConnection": "Host=187.127.102.190;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
"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.
>
> **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
- [~] 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,
emergencyContactRelationship: employee.emergencyContactRelationship,
emergencyContactPhone: employee.emergencyContactPhone,
hireDate: employee.hireDate,
confirmationDate: employee.confirmationDate,
lastWorkingDate: employee.lastWorkingDate,
departmentId: employee.departmentId,
@@ -3,8 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
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 { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -195,10 +194,10 @@ export default function PurchaseOrderDetailPage() {
const updated = await purchaseOrdersApi.submit(po.poId)
setPo(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) {
setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
@@ -283,9 +282,9 @@ export default function PurchaseOrderDetailPage() {
<div className="flex flex-wrap items-center gap-3">
{po.status === "Draft" && (
<>
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" />
{submitting ? "Submitting" : "Submit"}
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Check className="size-5" />
{submitting ? "Approving" : "Approve"}
</Button>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
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 { requisitionsApi } from "@/lib/api/requisitions"
@@ -15,13 +15,22 @@ import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { 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 { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
@@ -43,10 +52,10 @@ function newKey() {
return `poline-${keySeq}`
}
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
// remain on the payload because the backend line DTO still requires them; a PO prefilled
// from an RFQ keeps its negotiated price (below).
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
// there) and stays off this form, though it remains on the payload since the backend line
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
// from its negotiated price (below) but stays editable.
function emptyLine(): DraftLine {
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 [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(() => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
warehousesApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }),
])
.then(([it, uo, wh, ve]) => {
setItems(it.items)
setUoms(uo.items)
setWarehouses(wh.items)
setVendors(ve.items)
})
.catch((err) => setLoadError(errorMessage(err)))
loadItems(),
uomsApi.list().then((uo) => setUoms(uo.items)),
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
loadVendors(),
]).catch((err) => setLoadError(errorMessage(err)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 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(() => {
if (requisitionId) {
requisitionsApi
@@ -236,43 +306,72 @@ function NewPurchaseOrderContent() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex flex-col gap-2">
<Label className="text-base">Vendor code</Label>
<Select<number | null>
value={vendorId}
onValueChange={setVendorId}
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select vendor code" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Vendor name</Label>
<Select<number | null>
value={vendorId}
onValueChange={setVendorId}
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select vendor name" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:col-span-1">
<Label className="text-base">Vendor</Label>
<div className="flex items-center gap-2">
<Select<number | null>
value={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" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
<DialogTrigger
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
>
<Plus className="size-5" />
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!vErrors.name}>
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
{vSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{requisitionId && (
<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 items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
<div className="flex items-center gap-2">
<Link
href="/dashboard/products/new"
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline" }))}
title="Opens in a new tab — the item list here refreshes when you come back"
>
<ExternalLink className="size-5" />
New item
</Link>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
</div>
{lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -313,11 +425,15 @@ function NewPurchaseOrderContent() {
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{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 })}>
<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" />
</SelectTrigger>
<SelectContent>
@@ -374,6 +490,18 @@ function NewPurchaseOrderContent() {
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
@@ -2,9 +2,9 @@
import { useEffect, useState } from "react"
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 { errorMessage } from "@/lib/error-map"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [approvingId, setApprovingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
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"
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">Grand total</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => (
<TableRow key={po.poId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
{pos.map((po) => {
const editable = isPoEditable(po.status)
return (
<TableRow key={po.poId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<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>
</Table>
@@ -1,6 +1,6 @@
"use client"
import { Plus, Trash2, X } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { CustomFieldType, StageInputSource } from "@/types/production"
@@ -15,6 +15,7 @@ import {
} from "./types"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -175,265 +176,264 @@ export function StageEditorPanel({
}
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">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
<X className="size-5" />
</button>
</div>
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Stage editor</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field>
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</Field>
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</Field>
<Field>
<FieldLabel>Estimated minutes</FieldLabel>
<Input
type="number"
min={0}
value={data.estimatedMinutes}
disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/>
</Field>
<Field>
<FieldLabel>Estimated minutes</FieldLabel>
<Input
type="number"
min={0}
value={data.estimatedMinutes}
disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/>
</Field>
{/* Inputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
{data.inputs.map((input) => (
<div key={input.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Select<StageInputSource>
value={input.source}
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)}
{/* Inputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
{data.inputs.map((input) => (
<div key={input.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Select<StageInputSource>
value={input.source}
onValueChange={(v) => v && changeInputSource(input.localId, 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}>
<SelectValue placeholder="Pick the finished-good item" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem value="Upstream" className="text-sm">Upstream</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>
{!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>
{/* 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 })}
<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 })}
/>
<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>
</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>
</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}>
<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"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
return new Set(named.map((n) => n.id))
}
export default function TemplateBuilderPage() {
function TemplateBuilderContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
@@ -765,46 +765,52 @@ export default function TemplateBuilderPage() {
</div>
)}
<div className="flex min-h-0 flex-1 gap-4">
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted && (
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={locked ? undefined : onNodesChange}
onEdgesChange={locked ? undefined : onEdgesChange}
onNodesDelete={locked ? undefined : onNodesDelete}
onConnect={locked ? undefined : onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
>
<Background />
<Controls showInteractive={!locked} />
<MiniMap pannable zoomable />
</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 className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted && (
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={locked ? undefined : onNodesChange}
onEdgesChange={locked ? undefined : onEdgesChange}
onNodesDelete={locked ? undefined : onNodesDelete}
onConnect={locked ? undefined : onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
>
<Background />
<Controls showInteractive={!locked} />
<MiniMap pannable zoomable />
</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>
)
}
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>
<Badge
variant="outline"
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-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}
</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 font-medium">{b.name}</TableCell>
<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 className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<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 { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
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 font-medium">{s.name}</TableCell>
<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 className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<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 font-medium">{c.name}</TableCell>
<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 className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
@@ -240,7 +240,7 @@ export default function ItemsPage() {
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"
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
@@ -440,220 +440,220 @@ export default function NewGrnPage() {
{!poLoading && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-96 px-3 text-sm">Item</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-20 px-3 text-sm">Qty</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">VAT %</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-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{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>
<Table className="text-base">
<TableHeader>
<TableRow>
<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-28 px-3 text-sm">Bin</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-24 px-3 text-sm">Disc %</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-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-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
</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>
) : (
<>
<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>
)
})()}
</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>
)}
{!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="font-semibold tabular-nums">
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
@@ -0,0 +1,485 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSaleDetailPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(null)
const [bundleName, setBundleName] = useState("")
const [bundlePrice, setBundlePrice] = useState(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
Promise.all([
bundleApi.getBundle(bundleSaleId),
bundleApi.listTemplates({ pageSize: 200 }),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => {
const data = bundleRes
setBundle(data)
setTemplates(templateRes.items)
setCustomers(custRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(data.customerId)
setWarehouseId(data.warehouseId)
setCashierUserId(data.cashierUserId)
setTemplateId(data.bundleSaleTemplateId)
setBundleName(data.bundleName)
setBundlePrice(data.bundlePrice)
setLines(
data.lines.map((line) => ({
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
includeInBundle: line.includeInBundle,
sortOrder: line.bundleSaleLineId,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id, items])
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
const isDraft = bundle?.status === "Draft"
const canEdit = isDraft
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = template?.lines[0]
if (!source) return
setLines((prev) => [...prev, createBlankLine(source)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
setBusy(true)
setError(null)
try {
const request: UpdateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
setBundle(res)
setEditing(false)
router.refresh()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function postBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
if (!check.canPost) {
setError("Resolve stock shortages before posting this bundle.")
return
}
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
const shortageIssues = bundle && bundle.status === "Draft"
? bundle.lines
.filter((line) => line.includeInBundle)
.filter((line) => Number(line.qty || 0) > 0)
.map((line) => ({
bundleSaleLineId: line.bundleSaleLineId,
itemId: line.itemId,
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
warehouseId: line.warehouseId,
requestedQty: line.qty,
}))
: []
async function cancelBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-4" />
Print
</Link>
{canEdit ? (
editing ? (
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
<Save className="size-4" />
Save
</Button>
) : (
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
<Edit className="size-4" />
Edit
</Button>
)
) : null}
{isDraft ? (
<>
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
<CheckCircle2 className="size-4" />
Post
</Button>
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
<XCircle className="size-4" />
Cancel
</Button>
</>
) : null}
</div>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Component breakdown</h2>
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
</div>
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
{editing && isDraft ? <TableHead /> : null}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">
{editing && isDraft ? (
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
) : (
line.includeInBundle ? "Included" : "Excluded"
)}
</TableCell>
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
</TableRow>
))}
{lines.length === 0 ? (
<TableRow>
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add line to start.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</section>
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,350 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
uomId: item?.baseUomId ?? line.uomId,
})
})
: [createBlankLine()]
)
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [items, templateId])
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
return
}
setSaving(true)
setSubmitError(null)
try {
const request: CreateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.createBundle(request)
toast.success("Bundle saved", res.bundleNo)
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Need stock before saving this bundle?</div>
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
</div>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Override allowed</Label>
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
{allowPriceOverride ? "Yes" : "No"}
</button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Editable component rows</h2>
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
uomId: item?.baseUomId ?? line.uomId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
</TableCell>
</TableRow>
))}
{lines.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add component to start.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving..." : "Save draft"}
</Button>
</div>
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -0,0 +1,308 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
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 { PaginationMeta } from "@/types/common"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSalesPage() {
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cust, whRes]) => {
setCustomers(cust.items)
setWarehouses(whRes.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setError(null)
bundleApi
.listBundles({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
q: query || undefined,
customerId: customerId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(
() =>
rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
.toLowerCase()
.includes(query.toLowerCase())
return matchesStatus && matchesQuery
}) ?? [],
[rows, query, status]
)
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Sales</h1>
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Bundle
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by bundle, code, or customer"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Bundle</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
{row.bundleNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="inline-flex gap-2">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View bundle ${row.bundleNo}`}>
<Eye className="size-4" />
</Link>
<Link href={`/print/sales/bundles/${row.bundleSaleId}`} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Print bundle ${row.bundleNo}`}>
<Printer className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Rows loaded</div>
<div className="font-mono text-base font-semibold tabular-nums">{visibleRows.length}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="font-mono text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function BundleRegisterPage() {
redirect("/dashboard/sales/bundles")
}
@@ -0,0 +1,27 @@
"use client"
import Link from "next/link"
import { ArrowLeft, FileText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export default function BundleReportsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Reports</h1>
<p className="text-base text-muted-foreground">Bundle-level reporting will be added after the backend module is wired.</p>
</div>
</div>
<div className="rounded-2xl border border-dashed p-8 text-muted-foreground">
<FileText className="mb-3 size-6" />
This screen is a placeholder for bundle sales reporting.
</div>
</div>
)
}
@@ -182,12 +182,13 @@ export default function NewFreeIssuePage() {
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId || null,
parentLineId: line.parentLineId ?? null,
})),
}
if (editingRowId) {
const latest = await salesApi.getFreeIssue(editingRowId)
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
toast.success("Free issue updated")
setEditingRowId(null)
@@ -3,9 +3,8 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { companyApi } from "@/lib/api/company"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
@@ -14,7 +13,6 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { CompanyProfile } from "@/types/company"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
@@ -64,7 +62,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const invoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
@@ -86,15 +83,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
Promise.all([
companyApi.getProfile(),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
])
.then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => {
setCompany(companyRes.data)
.then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => {
setCustomers(customerRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
@@ -162,6 +157,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
}
@@ -186,14 +186,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
customerId,
warehouseId,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
@@ -269,7 +269,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
</div>
</div>
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}/print`} className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}>
<Link
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
target="_blank"
rel="noopener noreferrer"
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
>
<Printer className="size-4" />
Print
</Link>
@@ -281,9 +286,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="border-b pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Status:</span>
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", statusClass(invoice.status))}>{invoice.status}</span>
<span>Type: {invoice.invoiceType}</span>
@@ -291,12 +296,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
</div>
<div className="text-sm md:text-right">
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
<div className="text-muted-foreground">
{company?.addressLine1 ?? ""}
{company?.city ? `, ${company.city}` : ""}
</div>
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Company details are not configured for this invoice view.</div>
</div>
</div>
</div>
@@ -313,7 +314,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="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">Location: {warehouse?.location ?? "—"}</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
@@ -383,8 +383,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
<div className="border-t pt-5">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
@@ -418,7 +429,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="border-t pt-5">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
<p className="mt-2 text-sm text-muted-foreground">{company?.footerNote ?? "Standard invoice template view."}</p>
<p className="mt-2 text-sm text-muted-foreground">Standard invoice template view.</p>
</div>
<div className="mt-6 border-t pt-5 print:hidden">
@@ -437,6 +448,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<Save className="size-4" />
{saving ? "Saving..." : "Save invoice"}
</button>
{postingCheck && !postingCheck.canPost ? (
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Resolve stock in GRN
</Link>
) : null}
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
@@ -501,15 +521,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
/>
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
<td className="px-4 py-3 w-32">
<input
type="number"
@@ -519,6 +539,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</td>
<td className="px-4 py-3 w-24 text-right">
<button
@@ -5,7 +5,6 @@ import Link from "next/link"
import { ArrowLeft, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { companyApi } from "@/lib/api/company"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
@@ -16,14 +15,12 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { Customer } from "@/types/customers"
import { CompanyProfile } from "@/types/company"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { SalesInvoice } from "@/types/sales"
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
const invoiceId = Number(params.id)
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
@@ -36,15 +33,13 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
return
}
Promise.all([
companyApi.getProfile(),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
])
.then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => {
setCompany(companyRes.data)
.then(([cust, itemRes, uomRes, whRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
@@ -94,10 +89,8 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
</div>
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
{company?.logoUrl ? <img src={company.logoUrl} alt={company.tradeName ?? company.legalName} className="h-12 w-auto object-contain" /> : null}
<div className="text-muted-foreground">{company?.addressLine1}{company?.city ? `, ${company.city}` : ""}</div>
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
@@ -181,9 +174,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
<p className="mt-2 text-sm text-muted-foreground">
{company?.footerNote ?? "Standard invoice print view."}
</p>
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
</div>
<div className="rounded-2xl border p-4">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
@@ -199,19 +190,6 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
</div>
</div>
{company ? (
<div className="mt-6 rounded-2xl border bg-muted/20 p-4">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Bank Details</div>
<div className="mt-3 grid gap-2 text-sm md:grid-cols-2">
<div><span className="text-muted-foreground">Bank</span> <span className="font-medium">{company.bankName ?? "—"}</span></div>
<div><span className="text-muted-foreground">Branch</span> <span className="font-medium">{company.bankBranch ?? "—"}</span></div>
<div><span className="text-muted-foreground">Account Name</span> <span className="font-medium">{company.accountName ?? "—"}</span></div>
<div><span className="text-muted-foreground">Account No</span> <span className="font-medium">{company.accountNumber ?? "—"}</span></div>
<div><span className="text-muted-foreground">SWIFT</span> <span className="font-medium">{company.swiftCode ?? "—"}</span></div>
<div><span className="text-muted-foreground">VAT</span> <span className="font-medium">{company.vatRegistrationNo ?? "—"}</span></div>
</div>
</div>
) : null}
</div>
</div>
)
@@ -99,17 +99,16 @@ export default function NewSalesInvoicePage() {
])
setActiveFocSchemes(
freeIssueRes.items.flatMap((issue) => {
const firstLine = issue.lines?.[0]
if (!firstLine) return []
const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId)
if (!issue.itemId) return []
const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId)
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
return [
{
id: issue.salesSlipId,
slipNo: issue.slipNo,
schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`,
schemeLabel: issue.schemeLabel,
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
productLabel: `${item?.sku ?? `SKU-${firstLine.itemId}`} - ${item?.name ?? firstLine.itemName ?? `Item ${firstLine.itemId}`}`,
productLabel: `${item?.sku ?? issue.itemSku} - ${item?.name ?? issue.itemName}`,
},
]
}),
@@ -401,6 +400,11 @@ export default function NewSalesInvoicePage() {
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -69,6 +69,7 @@ export default function SalesInvoicesPage() {
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
return (
<div className="flex flex-col gap-6">
@@ -78,10 +79,15 @@ export default function SalesInvoicesPage() {
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="lg">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print batch
</Button>
</Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Invoice
@@ -23,12 +23,12 @@ const sections = [
href: "/dashboard/sales/free-issues",
icon: PackageX,
},
{
title: "Reports",
description: "Sales report catalog and query entry point.",
href: "/dashboard/sales/reports",
icon: FileBarChart,
},
// {
// title: "Reports",
// description: "Sales report catalog and query entry point.",
// href: "/dashboard/sales/reports",
// icon: FileBarChart,
// },
]
export default function SalesHubPage() {
@@ -42,7 +42,7 @@ export default function SalesHubPage() {
</div>
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">
Invoices, slips, free issues, and reporting in one place.
Invoices, slips, and free issues in one place.
</p>
</div>
</div>
@@ -20,7 +20,7 @@ function formatHeader(key: string) {
.trim()
}
function formatCell(value: unknown) {
function formatCell(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
if (typeof value === "string") {
@@ -2,7 +2,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save, Send, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -155,6 +155,11 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
@@ -172,14 +177,14 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
customerId,
warehouseId,
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
@@ -251,6 +256,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/print/sales/slips/${slip.salesSlipId}`}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
<Printer className="size-4" />
Print
</Link>
<Button variant="outline" onClick={save} disabled={saving || locked}><Save className="size-4" />{saving ? "Saving..." : "Save"}</Button>
<Button variant="outline" onClick={post} disabled={!canPost}><Send className="size-4" />{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : actionBusy === "post" ? "Posting..." : "Post"}</Button>
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
@@ -326,10 +340,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
<TableCell className="px-4 py-3">
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
</TableRow>
@@ -341,8 +360,19 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
@@ -111,6 +111,11 @@ export default function NewSalesSlipPage() {
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
@@ -162,7 +167,7 @@ export default function NewSalesSlipPage() {
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
@@ -366,6 +371,11 @@ export default function NewSalesSlipPage() {
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -69,6 +69,7 @@ export default function SalesSlipsPage() {
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
return (
<div className="flex flex-col gap-6">
@@ -78,10 +79,15 @@ export default function SalesSlipsPage() {
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="lg">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print batch
</Button>
</Link>
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Slip
@@ -1,135 +1,22 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Building2, Save } from "lucide-react"
import { Building2 } from "lucide-react"
import { companyApi } from "@/lib/api/company"
import { errorMessage } from "@/lib/error-map"
import { buttonVariants } from "@/components/ui/button"
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() {
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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
<Building2 className="size-10 text-muted-foreground" />
<div>
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
</div>
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to Settings
</Link>
</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>
)
}
@@ -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() {
return (
<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).
</p>
</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>
)
}
+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 { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data"
@@ -44,7 +45,6 @@ export default function VendorsPage() {
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
@@ -52,8 +52,14 @@ export default function VendorsPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
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 generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
@@ -75,8 +81,11 @@ export default function VendorsPage() {
useEffect(load, [query, status, page])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
}, [])
function resetForm() {
setCode("")
setName("")
setTerms("")
setTaxReg("")
@@ -86,7 +95,6 @@ export default function VendorsPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
setErrors(nextErrors)
@@ -94,14 +102,15 @@ export default function VendorsPage() {
setSubmitting(true)
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}`)
setOpen(false)
resetForm()
load()
setAllVendorCodes((codes) => [...codes, result.data.code])
} catch (err) {
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
@@ -145,19 +154,18 @@ export default function VendorsPage() {
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record.</DialogDescription>
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
</DialogHeader>
<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}>
<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} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</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>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
+1
View File
@@ -27,6 +27,7 @@ export default function RootLayout({
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
data-scroll-behavior="smooth"
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
@@ -0,0 +1,74 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSale } from "@/types/bundles"
export default function BundlePrintPage() {
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
bundleApi.getBundle(bundleSaleId).then((res) => setBundle(res)).catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id])
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle print...</div>
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Bundle Sales</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{bundle.bundleNo}</h1>
<p className="mt-2 text-sm text-muted-foreground">{bundle.bundleName}</p>
</div>
<div className="grid gap-3 text-sm md:grid-cols-3">
<div><div className="text-muted-foreground">Customer</div><div className="font-medium">{bundle.customerSnapshotName}</div></div>
<div><div className="text-muted-foreground">Warehouse</div><div className="font-medium">{bundle.warehouseId}</div></div>
<div><div className="text-muted-foreground">Status</div><div className="font-medium">{bundle.status}</div></div>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bundle.lines.map((line) => (
<TableRow key={line.bundleSaleLineId}>
<TableCell>{line.itemId}</TableCell>
<TableCell>{line.description}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
@@ -0,0 +1,99 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSaleSummary } from "@/types/bundles"
function statusClass(status: BundleSaleSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function BundleBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
bundleApi.listBundles({ page: 1, pageSize: 200 }).then((res) => setRows(res.items)).catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery = `${row.bundleNo} ${row.customerSnapshotName} ${row.bundleName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!rows) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle print data...</div>
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Bundle Sales</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Bundle Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current bundle sales.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bundle</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Grand</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.bundleSaleId}>
<TableCell className="font-medium">{row.bundleNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function BundleBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<BundleBatchPrintContent />
</Suspense>
)
}
@@ -0,0 +1,148 @@
"use client"
import { use, useEffect, useMemo, useState } from "react"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { SalesInvoice } from "@/types/sales"
export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const invoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
setError(`Invalid invoice id '${resolvedParams.id}'.`)
return
}
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
])
.then(([cust, itemRes, uomRes, whRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setInvoice(doc.data)
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, invoiceId])
const freeQtyTotal = useMemo(() => invoice?.totals.freeQtyTotal ?? 0, [invoice])
if (error && !invoice) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!invoice) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
}
const customer = customers.find((c) => c.customerId === invoice.customerId)
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
return (
<div className="invoice-sheet mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="invoice-header grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
</div>
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Invoice print view</div>
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[34%]">Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Discount</TableHead>
<TableHead className="text-right">Tax</TableHead>
<TableHead className="text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.salesInvoiceLineId}>
<TableCell>
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
@@ -0,0 +1,116 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesInvoiceSummary } from "@/types/sales"
function statusClass(status: SalesInvoiceSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function SalesInvoiceBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
salesApi
.listInvoices({ page: 1, pageSize: 200 })
.then((res) => setRows(res.items))
.catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!rows) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice batch print data is loading or unavailable."}</div>
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoices</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Invoice Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current invoices.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead className="text-right">Discount</TableHead>
<TableHead className="text-right">Net</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.salesInvoiceId}>
<TableCell className="font-medium">{row.invoiceNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell>{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="text-right">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="text-right">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function SalesInvoiceBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesInvoiceBatchPrintContent />
</Suspense>
)
}
@@ -0,0 +1,144 @@
"use client"
import { use, useEffect, useMemo, useState } from "react"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { SalesSlip } from "@/types/sales"
export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const slipId = Number(resolvedParams.id)
const [slip, setSlip] = useState<SalesSlip | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(slipId)) {
setError(`Invalid slip id '${resolvedParams.id}'.`)
return
}
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
salesApi.getSlip(slipId),
])
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setSlip(doc.data)
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, slipId])
const subtotal = useMemo(() => slip?.totals.subtotal ?? 0, [slip])
if (error && !slip) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!slip) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip print data is loading or unavailable."}</div>
}
const customer = customers.find((c) => c.customerId === slip.customerId)
const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId)
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-5">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slip</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h1>
<div className="mt-2 text-sm text-muted-foreground">Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 text-lg font-semibold text-foreground">{slip.customerSnapshotName}</div>
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${slip.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? slip.warehouseId}</div>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{slip.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{slip.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net total</div><div className="text-right font-semibold">{slip.totals.grandTotal.toFixed(2)}</div>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slip.lines.map((line) => (
<TableRow key={line.salesSlipLineId}>
<TableCell>
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cashier</div>
<div className="mt-2 text-sm text-foreground">{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</div>
<div className="mt-3 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
</div>
</div>
)
}
@@ -0,0 +1,114 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesSlipSummary } from "@/types/sales"
function statusClass(status: SalesSlipSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function SalesSlipBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
salesApi
.listSlips({ page: 1, pageSize: 200 })
.then((res) => setRows(res.items))
.catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!rows) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip batch print data is loading or unavailable."}</div>
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slips</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Slip Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current slips.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Slip</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead className="text-right">Discount</TableHead>
<TableHead className="text-right">Net</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.salesSlipId}>
<TableCell className="font-medium">{row.slipNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="text-right">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="text-right">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.totals.grandTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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 { usePathname } from "next/navigation"
import {
AlertOctagon,
AlertTriangle,
ArrowLeftRight,
Banknote,
BadgeDollarSign,
BookOpen,
@@ -29,6 +32,7 @@ import {
Menu,
Package,
PackageCheck,
PackageSearch,
PackageX,
PlayCircle,
PieChart,
@@ -36,6 +40,7 @@ import {
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
ShieldCheck,
ShoppingCart,
@@ -110,12 +115,32 @@ const navItems: {
children: [
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
{ 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: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
{
@@ -142,7 +167,7 @@ const navItems: {
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
{ title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock },
{ title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote },
{ title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart },
// { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart },
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
],
},
@@ -401,14 +426,15 @@ export function AppSidebar() {
// 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.
//
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
// server-side, which would hide the whole section for everyone. Remove each bypass once roles 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
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
// or their children server-side (stock's children specifically have no SubNavItem rows at
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
// 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
// anything server-side.
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
const visibleItems = loading
? []
: navItems
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
@@ -165,9 +165,12 @@ export function Header() {
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
// Read after mount, not during render: localStorage doesn't exist on the server, and
// reading it while rendering would desync the hydration pass.
const [user] = useState<AuthUser | null>(() => getStoredUser())
// Read after mount so the first client render matches the server render.
const [user, setUser] = useState<AuthUser | null>(null)
useEffect(() => {
setUser(getStoredUser())
}, [])
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
@@ -278,16 +281,18 @@ export function Header() {
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
<Avatar>
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
{initials(displayName(user))}
{user ? initials(displayName(user)) : "?"}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-foreground sm:block">
{displayName(user)}
{user ? displayName(user) : "Signed in"}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
<p className="text-base font-semibold text-foreground">
{user ? displayName(user) : "Signed in"}
</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
</div>
<DropdownMenuSeparator />
@@ -8,7 +8,7 @@ import { Plus } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
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 { Input } from "@/components/ui/input"
@@ -27,7 +27,7 @@ interface CodeNamed {
interface Api<T extends CodeNamed> {
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>
}
@@ -1,4 +1,4 @@
"use client"
"use client"
import Link from "next/link"
import { Lightbulb, PackageCheck } from "lucide-react"
@@ -11,7 +11,7 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
if (!suggestion || suggestion.lines.length === 0) {
return (
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
No free-issue promotion suggestions were generated for this slip yet.
No free-issue suggestions were generated for this slip yet.
</div>
)
}
@@ -22,10 +22,10 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
<Lightbulb className="size-3.5" />
Backend suggestion
Suggestion only
</div>
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
<p className="text-sm text-muted-foreground">These are suggestions only. You can review them before creating a free issue.</p>
</div>
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Create free issue
@@ -40,14 +40,12 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
<div className="font-medium text-foreground">{line.itemName}</div>
<div className="text-sm text-muted-foreground">{line.itemSku} Qty {line.qty}</div>
</div>
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
Buy {line.triggerQty} get {line.suggestedFreeQty} free
</div>
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Free issue</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-sm">
{line.rewardOptions.map((option, index) => (
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary")}>
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
{option.name}
</span>
@@ -55,11 +53,11 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
</div>
<div className="mt-3 text-sm text-muted-foreground">
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
Free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
)
}
}
+49
View File
@@ -0,0 +1,49 @@
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
BundleSale,
BundleSalePostingCheck,
BundleSaleSummary,
BundleSaleTemplate,
BundleSaleTemplateSummary,
CreateBundleSaleRequest,
UpdateBundleSaleRequest,
} from "@/types/bundles"
export const bundleApi = {
listBundles(params: { page?: number; pageSize?: number; status?: string; customerId?: number; warehouseId?: number; q?: string } = {}): Promise<PagedResponse<BundleSaleSummary>> {
return apiRequest<PagedResponse<BundleSaleSummary>>(`/bundle-sales${buildQuery(params)}`)
},
getBundle(bundleSaleId: number): Promise<BundleSale> {
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}`)
},
createBundle(request: CreateBundleSaleRequest): Promise<BundleSale> {
return apiRequest<BundleSale>("/bundle-sales", { method: "POST", body: request })
},
updateBundle(bundleSaleId: number, request: UpdateBundleSaleRequest): Promise<BundleSale> {
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}`, { method: "PUT", body: request })
},
postBundle(bundleSaleId: number): Promise<BundleSale> {
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}/post`, { method: "POST" })
},
cancelBundle(bundleSaleId: number): Promise<BundleSale> {
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}/cancel`, { method: "POST" })
},
checkBundlePosting(bundleSaleId: number): Promise<BundleSalePostingCheck> {
return apiRequest<BundleSalePostingCheck>(`/bundle-sales/${bundleSaleId}/posting-check`)
},
listTemplates(params: { page?: number; pageSize?: number; q?: string } = {}): Promise<PagedResponse<BundleSaleTemplateSummary>> {
return apiRequest<PagedResponse<BundleSaleTemplateSummary>>(`/bundle-sales/templates${buildQuery(params)}`)
},
getTemplate(bundleSaleTemplateId: number): Promise<BundleSaleTemplate> {
return apiRequest<BundleSaleTemplate>(`/bundle-sales/templates/${bundleSaleTemplateId}`)
},
}
+74 -16
View File
@@ -37,6 +37,11 @@ import {
ReceivedFromType,
CreateReceivedChequeRequest,
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"
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
* 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 = {
list(params?: {
async list(params?: {
bankAccountId?: number
branchId?: number
status?: ChequeBookStatus
page?: number
pageSize?: number
}): 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. */
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
async get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
const res = await glRequest<RawChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
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. */
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
async create(request: CreateChequeBookRequest): Promise<ChequeBook> {
const res = await glRequest<RawChequeBook>("/cheque-books", { method: "POST", body: request })
return mapChequeBook(res)
},
}
export const chequePagesApi = {
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
async issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
method: "PUT",
body: request,
})
return mapChequePage(res)
},
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
async updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
const res = await glRequest<RawChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
method: "PUT",
body: request,
})
return mapChequePage(res)
},
}
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
export const receivedChequesApi = {
list(params?: {
async list(params?: {
companyId?: number
branchId?: number
status?: ReceivedChequeStatus
@@ -308,15 +363,18 @@ export const receivedChequesApi = {
page?: number
pageSize?: number
}): 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> {
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
async create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
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`. */
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
async updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
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}`
}
+114
View File
@@ -0,0 +1,114 @@
import { EntityStatus } from "@/types/common"
export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled"
export interface BundleSaleTemplateLine {
bundleSaleTemplateLineId: number
itemId: number
uomId: number
warehouseId: number
qty: number
unitPrice: number
includeInBundle: boolean
sortOrder: number
}
export interface BundleSaleTemplateSummary {
bundleSaleTemplateId: number
templateCode: string
templateName: string
description: string | null
status: EntityStatus
lineCount: number
createdAt: string
updatedAt: string | null
}
export interface BundleSaleTemplate {
bundleSaleTemplateId: number
templateCode: string
templateName: string
description: string | null
status: EntityStatus
createdAt: string
updatedAt: string | null
lines: BundleSaleTemplateLine[]
}
export interface BundleSaleLine {
bundleSaleLineId: number
itemId: number
description: string
qty: number
uomId: number
warehouseId: number
unitPrice: number
lineTotal: number
includeInBundle: boolean
isComponent: boolean
parentLineId: number | null
}
export interface BundleSaleTotals {
componentSubtotal: number
bundlePrice: number
marginAmount: number
discountTotal: number
taxTotal: number
grandTotal: number
}
export interface BundleSaleSummary {
bundleSaleId: number
bundleNo: string
bundleDate: string
customerId: number
customerSnapshotName: string
warehouseId: number
bundleName: string
bundleCode: string
status: BundleSaleStatus
componentSubtotal: number
bundlePrice: number
grandTotal: number
createdAt: string
}
export interface BundleSale extends BundleSaleSummary, BundleSaleTotals {
cashierUserId: number
bundleSaleTemplateId: number
updatedAt: string | null
lines: BundleSaleLine[]
}
export interface BundleSalePostingIssue {
bundleSaleLineId: number
itemId: number
itemSku: string
itemName: string
warehouseId: number
requestedQty: number
availableQty: number
shortQty: number
}
export interface BundleSalePostingCheck {
bundleSaleId: number
bundleNo: string
status: BundleSaleStatus
canPost: boolean
issues: BundleSalePostingIssue[]
}
export interface CreateBundleSaleRequest {
customerId: number
warehouseId: number
cashierUserId: number
bundleSaleTemplateId: number
bundleName: string
bundlePrice: number
allowPriceOverride: boolean
lines: BundleSaleTemplateLine[]
}
export type UpdateBundleSaleRequest = CreateBundleSaleRequest
@@ -410,6 +410,59 @@ export enum ReceivedChequeStatusAction {
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`
* 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"],
},
],
})

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