Merge pull request 'Enhance/sales' (#25) from enhance/sales into Dev
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
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] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.ListAsync(query, 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));
|
||||||
|
}
|
||||||
@@ -19,4 +19,5 @@ public static class DocumentTypes
|
|||||||
public const string Production = "PRD";
|
public const string Production = "PRD";
|
||||||
public const string SalesInvoice = "SI";
|
public const string SalesInvoice = "SI";
|
||||||
public const string SalesSlip = "SSL";
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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 = 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 = 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 = 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 = 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 = 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 = 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.
|
// 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;
|
// 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.
|
// these procurement rows were never actually migrated into the database before now.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
dirty |= await SeedItemTypesAsync(db, ct);
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
dirty |= await SeedCompanyProfileAsync(db, ct);
|
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
dirty |= await SeedSalesStockAsync(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.
|
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
||||||
/// These values are intentionally editable later through the API.
|
/// These values are intentionally editable later through the API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
//{
|
||||||
if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
||||||
|
|
||||||
db.CompanyProfiles.Add(new CompanyProfile
|
// db.CompanyProfiles.Add(new CompanyProfile
|
||||||
{
|
// {
|
||||||
CompanyProfileId = CompanyProfile.SingletonId,
|
// CompanyProfileId = CompanyProfile.SingletonId,
|
||||||
LegalName = "ERP Core Trading (Pvt) Ltd",
|
// LegalName = "ERP Core Trading (Pvt) Ltd",
|
||||||
TradeName = "ERP Core Trading",
|
// TradeName = "ERP Core Trading",
|
||||||
TaxRegistrationNo = "TAX-DEFAULT-001",
|
// TaxRegistrationNo = "TAX-DEFAULT-001",
|
||||||
VatRegistrationNo = "VAT-DEFAULT-001",
|
// VatRegistrationNo = "VAT-DEFAULT-001",
|
||||||
AddressLine1 = "1 Demo Street",
|
// AddressLine1 = "1 Demo Street",
|
||||||
City = "Colombo",
|
// City = "Colombo",
|
||||||
Country = "Sri Lanka",
|
// Country = "Sri Lanka",
|
||||||
Phone = "+94 11 000 0000",
|
// Phone = "+94 11 000 0000",
|
||||||
Email = "accounts@example.com",
|
// Email = "accounts@example.com",
|
||||||
BankName = "Demo Bank",
|
// BankName = "Demo Bank",
|
||||||
BankBranch = "Colombo Main",
|
// BankBranch = "Colombo Main",
|
||||||
AccountName = "ERP Core Trading (Pvt) Ltd",
|
// AccountName = "ERP Core Trading (Pvt) Ltd",
|
||||||
AccountNumber = "000123456789",
|
// AccountNumber = "000123456789",
|
||||||
SwiftCode = "DEMO1234",
|
// SwiftCode = "DEMO1234",
|
||||||
FooterNote = "Thank you for your business."
|
// FooterNote = "Thank you for your business."
|
||||||
});
|
// });
|
||||||
return true;
|
// return true;
|
||||||
}
|
//}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds the minimum catalog data required for the sales demo rows to exist.
|
/// 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 SeedSalesSequencesAsync(db, ct);
|
||||||
dirty |= await SeedSampleSalesDocsAsync(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;
|
return dirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +378,7 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
var year = DateTime.UtcNow.Year;
|
var year = DateTime.UtcNow.Year;
|
||||||
var existing = await db.NumberSequences
|
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)
|
.Select(s => s.DocType)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -377,7 +386,8 @@ public static class DataSeeder
|
|||||||
var seeds = new[]
|
var seeds = new[]
|
||||||
{
|
{
|
||||||
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
|
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();
|
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
|
||||||
@@ -387,6 +397,209 @@ public static class DataSeeder
|
|||||||
return true;
|
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)
|
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
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) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|||||||
+1057
-115
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace ERPCore.Migrations
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class InitialCreate : Migration
|
public partial class SyncCurrentModel : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations;
|
||||||
|
|
||||||
|
public partial class AddBundleSalesModule : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_templates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table => table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId));
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sales",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
||||||
|
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
|
||||||
|
table.ForeignKey("FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sales_customers_CustomerId", x => x.CustomerId, "customers", "CustomerId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sales_users_CashierUserId", x => x.CashierUserId, "users", "UserId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sales_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_template_lines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
|
||||||
|
table.ForeignKey("FK_bundle_sale_template_lines_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey("FK_bundle_sale_template_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sale_template_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sale_template_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bundle_sale_lines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UomId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
ParentLineId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
|
||||||
|
table.ForeignKey("FK_bundle_sale_lines_bundle_sales_BundleSaleId", x => x.BundleSaleId, "bundle_sales", "BundleSaleId", onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey("FK_bundle_sale_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sale_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey("FK_bundle_sale_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_templates_TemplateCode", table: "bundle_sale_templates", column: "TemplateCode", unique: true);
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleNo", table: "bundle_sales", column: "BundleNo", unique: true);
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleSaleTemplateId", table: "bundle_sales", column: "BundleSaleTemplateId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CashierUserId", table: "bundle_sales", column: "CashierUserId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CustomerId", table: "bundle_sales", column: "CustomerId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_Status", table: "bundle_sales", column: "Status");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sales_WarehouseId", table: "bundle_sales", column: "WarehouseId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_BundleSaleTemplateId", table: "bundle_sale_template_lines", column: "BundleSaleTemplateId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_ItemId", table: "bundle_sale_template_lines", column: "ItemId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_UomId", table: "bundle_sale_template_lines", column: "UomId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_WarehouseId", table: "bundle_sale_template_lines", column: "WarehouseId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_BundleSaleId", table: "bundle_sale_lines", column: "BundleSaleId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_ItemId", table: "bundle_sale_lines", column: "ItemId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_UomId", table: "bundle_sale_lines", column: "UomId");
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_WarehouseId", table: "bundle_sale_lines", column: "WarehouseId");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable("bundle_sale_lines");
|
||||||
|
migrationBuilder.DropTable("bundle_sale_template_lines");
|
||||||
|
migrationBuilder.DropTable("bundle_sales");
|
||||||
|
migrationBuilder.DropTable("bundle_sale_templates");
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations;
|
||||||
|
|
||||||
|
public partial class AddBundleSalesConcurrencyStamp : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ConcurrencyStamp",
|
||||||
|
table: "bundle_sale_templates",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ConcurrencyStamp",
|
||||||
|
table: "bundle_sales",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ConcurrencyStamp",
|
||||||
|
table: "bundle_sales");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ConcurrencyStamp",
|
||||||
|
table: "bundle_sale_templates");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,9 @@ using ERPCore.Services.Stock;
|
|||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
using Npgsql;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -32,7 +34,10 @@ builder.Services.AddControllers()
|
|||||||
|
|
||||||
// EF Core + PostgreSQL
|
// EF Core + PostgreSQL
|
||||||
builder.Services.AddDbContext<ErpDbContext>(o =>
|
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
|
// ProblemDetails (RFC 7807) + domain-exception mapping
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
@@ -78,7 +83,7 @@ builder.Services.AddScoped<IUomService, UomService>();
|
|||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||||
builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
@@ -103,6 +108,7 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
|||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
|
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
|
|
||||||
@@ -180,6 +186,7 @@ var app = builder.Build();
|
|||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||||||
|
// await EnsureMigrationBaselineAsync(db);
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -205,3 +212,4 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
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 ISalesPricingService _pricing;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
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,
|
||||||
|
ISalesPricingService pricing,
|
||||||
|
IFifoCostingService fifo,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
INumberSequenceService numbers,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_templates = templates;
|
||||||
|
_bundles = bundles;
|
||||||
|
_customers = customers;
|
||||||
|
_items = items;
|
||||||
|
_uoms = uoms;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_users = users;
|
||||||
|
_pricing = pricing;
|
||||||
|
_fifo = fifo;
|
||||||
|
_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, 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 (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 async Task<BundleSalePostingCheckDto> CheckPostingAsync(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))
|
||||||
|
{
|
||||||
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
|
if (available >= line.Qty) 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 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 async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, 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.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 ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, 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.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)
|
||||||
|
{
|
||||||
|
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 posted.");
|
||||||
|
|
||||||
|
var check = await CheckPostingAsync(bundleSaleId, ct);
|
||||||
|
if (!check.CanPost)
|
||||||
|
throw new ConflictException("Resolve stock shortages before posting this bundle sale.");
|
||||||
|
|
||||||
|
var posted = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||||
|
{
|
||||||
|
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, nameof(BundleSale), bundle.BundleSaleId, DateTime.UtcNow, token);
|
||||||
|
}
|
||||||
|
bundle.Status = BundleSaleStatus.Posted;
|
||||||
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
|
bundle.ConcurrencyStamp++;
|
||||||
|
return bundle;
|
||||||
|
}, ct);
|
||||||
|
return Map(posted);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, int templateId, 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.");
|
||||||
|
if (!await _templates.Query().AnyAsync(x => x.BundleSaleTemplateId == templateId, ct))
|
||||||
|
throw new NotFoundException($"Bundle template {templateId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<BundleSaleLine>> BuildLinesAsync(BundleSaleTemplate template, 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)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||||
|
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||||
|
lines.Add(new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = r.ItemId,
|
||||||
|
Description = item.Name,
|
||||||
|
Qty = r.Qty,
|
||||||
|
UomId = r.UomId,
|
||||||
|
WarehouseId = r.WarehouseId,
|
||||||
|
UnitPrice = resolved.UnitPrice,
|
||||||
|
LineTotal = r.Qty * resolved.UnitPrice,
|
||||||
|
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,355 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, CheckCircle2, Edit, 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 }
|
||||||
|
|
||||||
|
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: 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])
|
||||||
|
|
||||||
|
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 = lines[lines.length - 1]
|
||||||
|
if (!source) return
|
||||||
|
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
<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, 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={!editing || !isDraft}>
|
||||||
|
<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">{line.includeInBundle ? "Yes" : "No"}</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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, 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 { 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 blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||||
|
|
||||||
|
export default function NewBundleSalePage() {
|
||||||
|
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.map(blankLine))
|
||||||
|
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||||
|
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||||
|
}, [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() {
|
||||||
|
const source = lines[lines.length - 1] ?? template?.lines[0]
|
||||||
|
if (!source) return
|
||||||
|
setLines((prev) => [...prev, blankLine(source)])
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
<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,
|
||||||
|
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) })}>
|
||||||
|
<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">{line.includeInBundle ? "Yes" : "No"}</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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"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 ?? [], [rows])
|
||||||
|
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),
|
discountValue: Number(line.discountValue),
|
||||||
taxPct: Number(line.taxPct),
|
taxPct: Number(line.taxPct),
|
||||||
isFreeIssue: line.isFreeIssue,
|
isFreeIssue: line.isFreeIssue,
|
||||||
parentLineId: line.parentLineId || null,
|
parentLineId: line.parentLineId ?? null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingRowId) {
|
if (editingRowId) {
|
||||||
const latest = await salesApi.getFreeIssue(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)
|
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
|
||||||
toast.success("Free issue updated")
|
toast.success("Free issue updated")
|
||||||
setEditingRowId(null)
|
setEditingRowId(null)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { companyApi } from "@/lib/api/company"
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -14,7 +13,6 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { CompanyProfile } from "@/types/company"
|
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
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 invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -86,15 +83,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
companyApi.getProfile(),
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
customersApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
salesApi.getInvoice(invoiceId),
|
salesApi.getInvoice(invoiceId),
|
||||||
])
|
])
|
||||||
.then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
.then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
||||||
setCompany(companyRes.data)
|
|
||||||
setCustomers(customerRes.items)
|
setCustomers(customerRes.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.items)
|
setUoms(uomRes.items)
|
||||||
@@ -269,7 +264,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Printer className="size-4" />
|
||||||
Print
|
Print
|
||||||
</Link>
|
</Link>
|
||||||
@@ -281,9 +281,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<div className="border-b pb-4">
|
<div className="border-b pb-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</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>
|
<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="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>Status:</span>
|
<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 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>
|
<span>Type: {invoice.invoiceType}</span>
|
||||||
@@ -291,12 +291,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm md:text-right">
|
<div className="text-sm md:text-right">
|
||||||
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
|
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">Company details are not configured for this invoice view.</div>
|
||||||
{company?.addressLine1 ?? ""}
|
|
||||||
{company?.city ? `, ${company.city}` : ""}
|
|
||||||
</div>
|
|
||||||
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +414,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
|
|
||||||
<div className="border-t pt-5">
|
<div className="border-t pt-5">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
<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>
|
||||||
|
|
||||||
<div className="mt-6 border-t pt-5 print:hidden">
|
<div className="mt-6 border-t pt-5 print:hidden">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link"
|
|||||||
import { ArrowLeft, Printer } from "lucide-react"
|
import { ArrowLeft, Printer } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { companyApi } from "@/lib/api/company"
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { CompanyProfile } from "@/types/company"
|
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { SalesInvoice } from "@/types/sales"
|
import { SalesInvoice } from "@/types/sales"
|
||||||
|
|
||||||
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
||||||
const invoiceId = Number(params.id)
|
const invoiceId = Number(params.id)
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -36,15 +33,13 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Promise.all([
|
Promise.all([
|
||||||
companyApi.getProfile(),
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
customersApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
salesApi.getInvoice(invoiceId),
|
salesApi.getInvoice(invoiceId),
|
||||||
])
|
])
|
||||||
.then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => {
|
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
||||||
setCompany(companyRes.data)
|
|
||||||
setCustomers(cust.items)
|
setCustomers(cust.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.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 className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 text-sm md:justify-items-end">
|
<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>
|
<div className="font-semibold text-foreground">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 details are not configured for this print view.</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="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</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">Printed: {new Date().toLocaleString()}</div>
|
||||||
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</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="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
|
||||||
{company?.footerNote ?? "Standard invoice print view."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border p-4">
|
<div className="rounded-2xl border p-4">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,17 +99,16 @@ export default function NewSalesInvoicePage() {
|
|||||||
])
|
])
|
||||||
setActiveFocSchemes(
|
setActiveFocSchemes(
|
||||||
freeIssueRes.items.flatMap((issue) => {
|
freeIssueRes.items.flatMap((issue) => {
|
||||||
const firstLine = issue.lines?.[0]
|
if (!issue.itemId) return []
|
||||||
if (!firstLine) return []
|
const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId)
|
||||||
const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId)
|
|
||||||
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
|
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: issue.salesSlipId,
|
id: issue.salesSlipId,
|
||||||
slipNo: issue.slipNo,
|
slipNo: issue.slipNo,
|
||||||
schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`,
|
schemeLabel: issue.schemeLabel,
|
||||||
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
|
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}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function SalesInvoicesPage() {
|
|||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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>
|
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" />
|
<Printer className="size-5" />
|
||||||
Print batch
|
Print batch
|
||||||
</Button>
|
</Link>
|
||||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Invoice
|
New Invoice
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Minus, Plus, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -251,6 +251,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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={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="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>
|
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function SalesSlipsPage() {
|
|||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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>
|
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" />
|
<Printer className="size-5" />
|
||||||
Print batch
|
Print batch
|
||||||
</Button>
|
</Link>
|
||||||
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Slip
|
New Slip
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<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,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { 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 { 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleBatchPrintPage() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { 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 { 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesInvoiceBatchPrintPage() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { 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 { 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesSlipBatchPrintPage() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||||
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
{ 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: "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 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 14 · BACKEND — Sales API Reference
|
# 14 · BACKEND — Sales API Reference
|
||||||
|
|
||||||
> **Authoritative for:** sales API contracts for invoices, slips, free issues, and sales reports.
|
> **Authoritative for:** sales API contracts for invoices, slips, bundle sales, free issues, and sales reports.
|
||||||
> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`.
|
> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`.
|
||||||
> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table.
|
> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table.
|
||||||
|
|
||||||
@@ -134,7 +134,60 @@ Cancels a draft slip.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Free Issues
|
## 4. Bundle Sales
|
||||||
|
|
||||||
|
Bundle sales are a separate sales document family for fixed bundle compositions.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
Returns a paged list of `BundleSaleSummaryDto`.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Returns `BundleSaleDto`.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check`
|
||||||
|
Validates component stock before posting.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales`
|
||||||
|
Creates a draft bundle sale from a fixed template.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": 2,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"cashierUserId": 1,
|
||||||
|
"bundleSaleTemplateId": 1,
|
||||||
|
"bundleName": "Summer Promo Pack",
|
||||||
|
"bundleCode": "BND-001",
|
||||||
|
"bundlePrice": 2500,
|
||||||
|
"allowPriceOverride": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Updates a draft bundle sale. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/post`
|
||||||
|
Posts the bundle and consumes stock from the included component lines.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel`
|
||||||
|
Cancels a draft bundle sale.
|
||||||
|
|
||||||
|
Business rule:
|
||||||
|
- bundle composition is fixed by template lines
|
||||||
|
- posting consumes component stock, not a synthetic bundle stock item
|
||||||
|
- print pages should show both the bundle summary and the component breakdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Free Issues
|
||||||
|
|
||||||
Free issue is a business alias over sales slips.
|
Free issue is a business alias over sales slips.
|
||||||
There is no separate free-issue table in the current schema.
|
There is no separate free-issue table in the current schema.
|
||||||
@@ -165,7 +218,7 @@ Business rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Sales Reports
|
## 6. Sales Reports
|
||||||
|
|
||||||
### `GET /api/v1/reports/sales`
|
### `GET /api/v1/reports/sales`
|
||||||
Returns the report catalog.
|
Returns the report catalog.
|
||||||
@@ -217,8 +270,9 @@ Validation rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Notes
|
## 7. Notes
|
||||||
|
|
||||||
- The sales report service reads both invoices and slips where relevant.
|
- The sales report service reads both invoices and slips where relevant.
|
||||||
- Free issue reporting is derived from the same sales document lines.
|
- Free issue reporting is derived from the same sales document lines.
|
||||||
|
- Bundle sales are treated as a separate fixed-composition document family.
|
||||||
- There is no separate free-issue table in the current schema.
|
- There is no separate free-issue table in the current schema.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 15 · BACKEND — Bundle Sales API
|
||||||
|
|
||||||
|
> **Authoritative for:** fixed-composition bundle sales, templates, posting, and print data.
|
||||||
|
> **Navigation:** start from `00-CORE.md`. This module follows the same repository/UoW/ETag/audit patterns as invoices and slips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Concept
|
||||||
|
|
||||||
|
Bundle sales are a separate sales document family for fixed bundle compositions.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- a bundle sale is created from a bundle template
|
||||||
|
- the bundle template defines fixed component stock lines
|
||||||
|
- posting consumes stock from the component items, not from a synthetic bundle SKU
|
||||||
|
- the bundle header carries the commercial sale value
|
||||||
|
- print views show both bundle summary and component breakdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Returns the bundle sale header and all component lines.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check`
|
||||||
|
Validates component stock before posting.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales`
|
||||||
|
Creates a draft bundle sale from a fixed bundle template.
|
||||||
|
|
||||||
|
### `PUT /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Updates a draft bundle sale. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/post`
|
||||||
|
Consumes stock from included component lines and marks the bundle as posted.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel`
|
||||||
|
Cancels a draft bundle sale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Template Rules
|
||||||
|
|
||||||
|
- bundle templates are fixed in composition
|
||||||
|
- each template line maps to one component stock item
|
||||||
|
- component quantities are expanded into the sale draft at creation time
|
||||||
|
- price override is allowed only when the caller is permitted by business rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Model
|
||||||
|
|
||||||
|
### `BundleSaleTemplate`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `TemplateCode`
|
||||||
|
- `TemplateName`
|
||||||
|
- `Description`
|
||||||
|
- `Status`
|
||||||
|
- `CreatedAt`
|
||||||
|
- `UpdatedAt`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
### `BundleSaleTemplateLine`
|
||||||
|
- `BundleSaleTemplateLineId`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `ItemId`
|
||||||
|
- `UomId`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `Qty`
|
||||||
|
- `UnitPrice`
|
||||||
|
- `IncludeInBundle`
|
||||||
|
- `SortOrder`
|
||||||
|
|
||||||
|
### `BundleSale`
|
||||||
|
- `BundleSaleId`
|
||||||
|
- `BundleNo`
|
||||||
|
- `BundleDate`
|
||||||
|
- `CustomerId`
|
||||||
|
- `CustomerSnapshotName`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `CashierUserId`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `BundleName`
|
||||||
|
- `BundleCode`
|
||||||
|
- `Status`
|
||||||
|
- `ComponentSubtotal`
|
||||||
|
- `BundlePrice`
|
||||||
|
- `MarginAmount`
|
||||||
|
- `DiscountTotal`
|
||||||
|
- `TaxTotal`
|
||||||
|
- `GrandTotal`
|
||||||
|
- `CreatedAt`
|
||||||
|
- `UpdatedAt`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
### `BundleSaleLine`
|
||||||
|
- `BundleSaleLineId`
|
||||||
|
- `BundleSaleId`
|
||||||
|
- `ItemId`
|
||||||
|
- `Description`
|
||||||
|
- `Qty`
|
||||||
|
- `UomId`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `UnitPrice`
|
||||||
|
- `LineTotal`
|
||||||
|
- `IncludeInBundle`
|
||||||
|
- `IsComponent`
|
||||||
|
- `ParentLineId`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Posting
|
||||||
|
|
||||||
|
Posting behavior:
|
||||||
|
- validate each included component line has sufficient stock
|
||||||
|
- consume FIFO layers from the component items
|
||||||
|
- write stock ledger rows for each component
|
||||||
|
- mark the bundle as posted in the same transaction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Notes
|
||||||
|
|
||||||
|
- This module is intentionally separate from invoices and slips.
|
||||||
|
- Bundle sales are for fixed compositions only in this phase.
|
||||||
|
- Print views should mirror the existing sales document print behavior without the dashboard shell.
|
||||||
@@ -282,6 +282,8 @@ Add richer commercial features after Phase 1 is stable and tested.
|
|||||||
- price lists
|
- price lists
|
||||||
- promotions
|
- promotions
|
||||||
- free issue schemes
|
- free issue schemes
|
||||||
|
- bundle sales templates
|
||||||
|
- bundle sales documents
|
||||||
- reservations
|
- reservations
|
||||||
- payment allocation
|
- payment allocation
|
||||||
- approval flow
|
- approval flow
|
||||||
@@ -304,6 +306,18 @@ Promotional header.
|
|||||||
#### `PromotionRule`
|
#### `PromotionRule`
|
||||||
Buy-X-get-Y, discount, or reward rules.
|
Buy-X-get-Y, discount, or reward rules.
|
||||||
|
|
||||||
|
#### `BundleSaleTemplate`
|
||||||
|
Fixed bundle composition definition.
|
||||||
|
|
||||||
|
#### `BundleSaleTemplateLine`
|
||||||
|
Component stock items and quantities inside a bundle template.
|
||||||
|
|
||||||
|
#### `BundleSale`
|
||||||
|
Posted or draft bundle sale header.
|
||||||
|
|
||||||
|
#### `BundleSaleLine`
|
||||||
|
Component lines expanded from a bundle template.
|
||||||
|
|
||||||
#### `FreeIssueScheme`
|
#### `FreeIssueScheme`
|
||||||
Separate free issue header.
|
Separate free issue header.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user