add day-end and change item model

This commit is contained in:
Dhananjaya99
2026-08-20 14:10:39 +05:30
parent 8835ba9500
commit 4cc6a546c7
43 changed files with 8955 additions and 1680 deletions
@@ -29,6 +29,14 @@ public sealed class BundleSalesController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List( public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
+1
View File
@@ -20,6 +20,7 @@ public static class DocumentTypes
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"; public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET"; public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary> /// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
+5 -4
View File
@@ -44,11 +44,12 @@ public class Item
public string? TaxClass { get; set; } public string? TaxClass { get; set; }
/// <summary> /// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value" /// Optional selling price range and flat price used by Sales only.
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price. /// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// </summary> /// </summary>
public decimal? SalePrice { get; set; } public decimal? SellingMinPrice { get; set; }
public decimal? SellingMaxPrice { get; set; }
public decimal? SellingFlatPrice { get; set; }
/// <summary> /// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with /// How much one pack holds, as the user entered it — <c>500</c> with
+8 -4
View File
@@ -10,7 +10,7 @@ public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths, Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice, string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit, decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status); EntityStatus Status);
@@ -31,7 +31,7 @@ public sealed record ItemDetailDto(
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths, Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice, string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit, decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
@@ -66,7 +66,9 @@ public sealed class CreateItemRequest
public int? WarrantyPeriodMonths { get; set; } public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; } [StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary> /// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } [Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary> /// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null /// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -96,7 +98,9 @@ public sealed class UpdateItemRequest
public int? WarrantyPeriodMonths { get; set; } public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; } [StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary> /// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } [Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary> /// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null /// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -51,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
public sealed class CreateBundleSaleTemplateRequest public sealed class CreateBundleSaleTemplateRequest
{ {
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty; [Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
+13 -2
View File
@@ -8,11 +8,21 @@ namespace ERPCore.Dtos.Sales;
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary> /// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue); public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
/// <summary>Read-only visibility into that cashier's Sales Invoices for the date — informational
/// only, appended to <see cref="SalesDayEndDto"/> and <see cref="SalesDayEndPreviewDto"/>.
/// Invoices post their own GL entry on Confirm (SalesPostingService.PostInvoiceAsync) and have no
/// DayEndId, so they're never locked against a close: these fields are computed live from current
/// data on every read, unlike the frozen Slip/Bundle totals alongside them.</summary>
public sealed record SalesDayEndInvoiceSummaryDto(
int InvoiceCount, decimal InvoiceSubtotal, decimal InvoiceDiscountTotal, decimal InvoiceTaxTotal,
decimal InvoiceGrandTotal, IReadOnlyList<string> InvoiceNumbers);
public sealed record SalesDayEndDto( public sealed record SalesDayEndDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate, int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt, decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown); IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record SalesDayEndSummaryDto( public sealed record SalesDayEndSummaryDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate, int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
@@ -25,7 +35,8 @@ public sealed record SalesDayEndPreviewDto(
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose, IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose); IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal); public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal); public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
@@ -4,7 +4,7 @@ public sealed record SalesFreeIssueRewardOptionDto(
int ItemId, int ItemId,
string Sku, string Sku,
string Name, string Name,
decimal? SalePrice); decimal? SellingFlatPrice);
public sealed record SalesFreeIssueSuggestionLineDto( public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId, int SalesSlipLineId,
@@ -20,4 +20,4 @@ public sealed record SalesFreeIssueSuggestionDto(
int SalesSlipId, int SalesSlipId,
string SlipNo, string SlipNo,
DateTime SlipDate, DateTime SlipDate,
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines); IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
@@ -20,7 +20,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.TaxClass).HasMaxLength(20); builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value). // Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4); builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingMaxPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingFlatPrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable // Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived // together: null ⇒ the item has no measurable content. The base pair is derived
+25 -25
View File
@@ -182,7 +182,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m, QtyReceived = 100m,
QtyRemaining = 100m, QtyRemaining = 100m,
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m, UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7) ReceiptDate = DateTime.UtcNow.AddDays(-7)
}); });
db.StockLayers.Add(new StockLayer db.StockLayers.Add(new StockLayer
@@ -191,7 +191,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m, QtyReceived = 5m,
QtyRemaining = 5m, QtyRemaining = 5m,
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m, UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6) ReceiptDate = DateTime.UtcNow.AddDays(-6)
}); });
return true; return true;
@@ -278,7 +278,7 @@ public static class DataSeeder
BaseUomId = uom.UomId, BaseUomId = uom.UomId,
StockNature = StockNature.Stocked, StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None, TrackingMode = TrackingMode.None,
SalePrice = 100m, SellingFlatPrice = 100m,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = now CreatedAt = now
}, },
@@ -292,7 +292,7 @@ public static class DataSeeder
BaseUomId = uom.UomId, BaseUomId = uom.UomId,
StockNature = StockNature.Stocked, StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None, TrackingMode = TrackingMode.None,
SalePrice = 50m, SellingFlatPrice = 50m,
ContentQty = 500m, ContentQty = 500m,
ContentUnit = MeasureUnit.Ml, ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m, ContentBaseQty = 500m,
@@ -445,7 +445,7 @@ public static class DataSeeder
ItemId = items[0].ItemId, ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
Qty = 1m, Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(), UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
SortOrder = 1 SortOrder = 1
}, },
@@ -454,7 +454,7 @@ public static class DataSeeder
ItemId = items[1].ItemId, ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
Qty = 1m, Qty = 1m,
UnitPrice = items[1].SalePrice.GetValueOrDefault(), UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
SortOrder = 2 SortOrder = 2
} }
@@ -478,10 +478,10 @@ public static class DataSeeder
BundleName = "Demo Bundle Draft", BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001", BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft, Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m, BundlePrice = 0m,
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()), MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
TaxTotal = 0m, TaxTotal = 0m,
GrandTotal = 0m, GrandTotal = 0m,
CreatedAt = now.AddDays(-2), CreatedAt = now.AddDays(-2),
@@ -493,8 +493,8 @@ public static class DataSeeder
Description = items[0].Name, Description = items[0].Name,
Qty = 1m, Qty = 1m,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(), UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
IsComponent = true IsComponent = true
}, },
@@ -504,8 +504,8 @@ public static class DataSeeder
Description = items[1].Name, Description = items[1].Name,
Qty = 1m, Qty = 1m,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(), UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(), LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
IsComponent = true IsComponent = true
} }
@@ -523,12 +523,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Posted", BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001", BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted, Status = BundleSaleStatus.Posted,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m, BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m, MarginAmount = -10m,
DiscountTotal = 10m, DiscountTotal = 10m,
TaxTotal = 0m, TaxTotal = 0m,
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m, GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1), CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2), UpdatedAt = now.AddHours(-2),
Lines = Lines =
@@ -539,8 +539,8 @@ public static class DataSeeder
Description = items[0].Name, Description = items[0].Name,
Qty = 1m, Qty = 1m,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(), UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
IsComponent = true IsComponent = true
}, },
@@ -550,8 +550,8 @@ public static class DataSeeder
Description = items[1].Name, Description = items[1].Name,
Qty = 1m, Qty = 1m,
WarehouseId = warehouse.WarehouseId, WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(), UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(), LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
IsComponent = true IsComponent = true
} }
@@ -569,12 +569,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Cancelled", BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001", BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled, Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(), ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = items[0].SalePrice.GetValueOrDefault(), BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m, MarginAmount = 0m,
DiscountTotal = 0m, DiscountTotal = 0m,
TaxTotal = 0m, TaxTotal = 0m,
GrandTotal = items[0].SalePrice.GetValueOrDefault(), GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
Lines = Lines =
@@ -585,8 +585,8 @@ public static class DataSeeder
Description = items[0].Name, Description = items[0].Name,
Qty = 1m, Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId, WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(), UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true, IncludeInBundle = true,
IsComponent = true IsComponent = true
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class itemModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingMinPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingFlatPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingFlatPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingMinPrice",
table: "items",
newName: "SalePrice");
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
public partial class UpdateItemSellingPrices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingFlatPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingMinPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingMinPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingFlatPrice",
table: "items",
newName: "SalePrice");
}
}
}
@@ -1745,7 +1745,15 @@ namespace ERPCore.Migrations
.HasColumnType("xid") .HasColumnType("xid")
.HasColumnName("xmin"); .HasColumnName("xmin");
b.Property<decimal?>("SalePrice") b.Property<decimal?>("SellingFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal?>("SellingMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal?>("SellingMinPrice")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("numeric(18,4)"); .HasColumnType("numeric(18,4)");
@@ -85,6 +85,45 @@ public sealed class BundleSaleService : IBundleSaleService
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
} }
public async Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default)
{
var template = new BundleSaleTemplate
{
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
TemplateName = request.TemplateName,
Description = request.Description,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
foreach (var line in request.Lines)
{
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
template.Lines.Add(new BundleSaleTemplateLine
{
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
Qty = line.Qty,
UnitPrice = line.UnitPrice,
IncludeInBundle = line.IncludeInBundle,
SortOrder = line.SortOrder
});
}
await _templates.AddAsync(template, ct);
await _uow.SaveChangesAsync(ct);
return 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.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{ {
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines); IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
@@ -9,6 +9,7 @@ public interface IBundleSaleService
{ {
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default); Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default); Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default); Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
+10 -4
View File
@@ -85,7 +85,8 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto( .Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId, i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice, i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status)) i.Status))
.ToListAsync(ct); .ToListAsync(ct);
@@ -130,7 +131,9 @@ public sealed class ItemService : IItemService
Warranty = request.Warranty, Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths, WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass, TaxClass = request.TaxClass,
SalePrice = request.SalePrice, SellingMinPrice = request.SellingMinPrice,
SellingMaxPrice = request.SellingMaxPrice,
SellingFlatPrice = request.SellingFlatPrice,
ContentQty = request.ContentQty, ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit, ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty, ContentBaseQty = contentBaseQty,
@@ -190,7 +193,9 @@ public sealed class ItemService : IItemService
item.Warranty = request.Warranty; item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths; item.WarrantyPeriodMonths = warrantyPeriodMonths;
item.TaxClass = request.TaxClass; item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice; item.SellingMinPrice = request.SellingMinPrice;
item.SellingMaxPrice = request.SellingMaxPrice;
item.SellingFlatPrice = request.SellingFlatPrice;
item.ContentQty = request.ContentQty; item.ContentQty = request.ContentQty;
item.ContentUnit = request.ContentUnit; item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty; item.ContentBaseQty = contentBaseQty;
@@ -364,7 +369,8 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new( private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId, i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice, i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status, i.Status,
i.ReorderSettings i.ReorderSettings
+30 -4
View File
@@ -34,6 +34,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly IRepository<SalesDayEnd> _dayEnds; private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<SalesSlip> _slips; private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles; private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Item> _items; private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
@@ -49,12 +50,13 @@ public sealed class SalesDayEndService : ISalesDayEndService
public SalesDayEndService( public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers, IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration) ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{ {
_dayEnds = dayEnds; _dayEnds = dayEnds;
_slips = slips; _slips = slips;
_bundles = bundles; _bundles = bundles;
_invoices = invoices;
_ledger = ledger; _ledger = ledger;
_items = items; _items = items;
_numbers = numbers; _numbers = numbers;
@@ -71,6 +73,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default) public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
{ {
var invoiceSummary = await LoadInvoiceSummaryAsync(cashierUserId, businessDate, ct);
var existing = await _dayEnds.Query().AsNoTracking() var existing = await _dayEnds.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct); .FirstOrDefaultAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
if (existing is not null) if (existing is not null)
@@ -89,7 +93,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
cashierUserId, businessDate, true, cashierUserId, businessDate, true,
existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal, existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal,
closedSlipNumbers, closedBundleNumbers, closedSlipNumbers, closedBundleNumbers,
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>()); Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>(), invoiceSummary);
} }
var openSlips = await LoadOpenSlipsAsync(cashierUserId, businessDate, ct); var openSlips = await LoadOpenSlipsAsync(cashierUserId, businessDate, ct);
@@ -111,7 +115,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(), postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(),
postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(), postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(),
draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(), draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(),
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList()); draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList(),
invoiceSummary);
} }
public async Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default) public async Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default)
@@ -265,6 +270,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
.ToListAsync(ct); .ToListAsync(ct);
} }
// Sales Invoices have no CashierUserId (they aren't a cashier document — SalesInvoiceService
// doesn't take one) so CreatedBy is the closest equivalent: whoever was logged in when they
// made the sale. Matched against Posted invoices only, same as the Slip/Bundle totals above.
private async Task<SalesDayEndInvoiceSummaryDto> LoadInvoiceSummaryAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct)
{
var (dayStart, dayEnd) = DayRangeUtc(businessDate);
var invoices = await _invoices.Query().AsNoTracking()
.Where(x => x.CreatedBy == cashierUserId && x.Status == SalesInvoiceStatus.Posted
&& x.InvoiceDate >= dayStart && x.InvoiceDate < dayEnd)
.ToListAsync(ct);
return new SalesDayEndInvoiceSummaryDto(
invoices.Count,
invoices.Sum(x => x.Subtotal),
invoices.Sum(x => x.DiscountTotal),
invoices.Sum(x => x.TaxTotal),
invoices.Sum(x => x.GrandTotal),
invoices.OrderBy(x => x.InvoiceNo).Select(x => x.InvoiceNo).ToList());
}
// SlipDate/BundleDate are stored as DateTime.UtcNow (Kind=Utc) throughout — Npgsql requires // SlipDate/BundleDate are stored as DateTime.UtcNow (Kind=Utc) throughout — Npgsql requires
// a matching Kind=Utc here too, or comparisons against the `timestamptz` column throw. // a matching Kind=Utc here too, or comparisons against the `timestamptz` column throw.
private static (DateTime Start, DateTime End) DayRangeUtc(DateOnly businessDate) private static (DateTime Start, DateTime End) DayRangeUtc(DateOnly businessDate)
@@ -310,10 +334,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
.OrderByDescending(x => x.Revenue) .OrderByDescending(x => x.Revenue)
.ToList(); .ToList();
var invoiceSummary = await LoadInvoiceSummaryAsync(entity.CashierUserId, entity.BusinessDate, ct);
return new SalesDayEndDto( return new SalesDayEndDto(
entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate, entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate,
entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal, entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal,
entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt, entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt,
slipNumbers, bundleNumbers, breakdown); slipNumbers, bundleNumbers, breakdown, invoiceSummary);
} }
} }
@@ -31,8 +31,8 @@ public sealed class SalesPricingService : ISalesPricingService
{ {
if (!allowManualOverride) if (!allowManualOverride)
{ {
if (item.SalePrice.HasValue) if (item.SellingFlatPrice.HasValue)
return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); return new SalesPriceResolution(item.SellingFlatPrice.Value, "SALE_PRICE", item.SellingFlatPrice.Value);
var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct);
if (grnPrice is not null) if (grnPrice is not null)
@@ -44,8 +44,8 @@ public sealed class SalesPricingService : ISalesPricingService
return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value); return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value);
} }
if (item.SalePrice.HasValue) if (item.SellingFlatPrice.HasValue)
return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); return new SalesPriceResolution(item.SellingFlatPrice.Value, "SALE_PRICE", item.SellingFlatPrice.Value);
var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct);
if (weighted is not null) if (weighted is not null)
@@ -46,14 +46,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
var rewardOptions = new List<SalesFreeIssueRewardOptionDto> var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
{ {
new(item.ItemId, item.Sku, item.Name, item.SalePrice) new(item.ItemId, item.Sku, item.Name, item.SellingFlatPrice)
}; };
var alternates = await _items.Query().AsNoTracking() var alternates = await _items.Query().AsNoTracking()
.Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId) .Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId)
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Take(3) .Take(3)
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice)) .Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SellingFlatPrice))
.ToListAsync(ct); .ToListAsync(ct);
rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId))); rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId)));
@@ -58,6 +58,9 @@ export default function ItemDetailPage() {
const [warranty, setWarranty] = useState<Warranty>("NonWarranty") const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null) const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("") const [taxClass, setTaxClass] = useState("")
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0. // Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("") const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null) const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -86,6 +89,9 @@ export default function ItemDetailPage() {
setWarranty(data.warranty) setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths) setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "") setTaxClass(data.taxClass ?? "")
setSellingMinPrice(data.sellingMinPrice === null ? "" : String(data.sellingMinPrice))
setSellingMaxPrice(data.sellingMaxPrice === null ? "" : String(data.sellingMaxPrice))
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
setContentQty(data.contentQty === null ? "" : String(data.contentQty)) setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit) setContentUnit(data.contentUnit)
} }
@@ -130,6 +136,9 @@ export default function ItemDetailPage() {
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null, taxClass: taxClass || null,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null, contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null, contentUnit: contentQty.trim() ? contentUnit : null,
}, },
@@ -246,6 +255,48 @@ export default function ItemDetailPage() {
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> <Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Category</Label> <Label className="text-base">Category</Label>
<Select<number | null> <Select<number | null>
@@ -128,13 +128,16 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it. // submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set()) const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value); // Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that // "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only. // pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock") const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock")
const [fixValue, setFixValue] = useState<string>("") const [fixValue, setFixValue] = useState<string>("")
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({}) const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({}) const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override. // Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty") const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
@@ -401,7 +404,11 @@ export default function NewItemPage() {
trackingMode: "None", trackingMode: "None",
warranty, warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null, warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null, sellingMinPrice: null,
sellingMaxPrice: null,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : (sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null),
// Each variant carries its OWN size when a measurement dimension supplied one; // Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension // otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml). // isn't size (colour variants of one 500 ml bottle all hold 500 ml).
@@ -639,8 +646,58 @@ export default function NewItemPage() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
<p className="text-sm text-muted-foreground">
Optional values for the item. Leave any field blank if it does not apply.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
</div>
</div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends {/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */} sellingFlatPrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
<div className="flex flex-col gap-4 rounded-xl border p-5"> <div className="flex flex-col gap-4 rounded-xl border p-5">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2> <h2 className="text-lg font-semibold text-foreground">Sale price</h2>
@@ -1,438 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
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, BundleSaleTemplate, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSaleDetailPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | 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 [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,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
includeInBundle: line.includeInBundle,
sortOrder: line.bundleSaleLineId,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id, items])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then(setTemplate).catch((err) => setError(errorMessage(err)))
}, [templateId])
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
const isDraft = bundle?.status === "Draft"
const canEdit = isDraft
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = template?.lines[0]
if (!source) return
setLines((prev) => [...prev, createBlankLine(source)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
setBusy(true)
setError(null)
try {
const request: UpdateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
setBundle(res)
setEditing(false)
router.refresh()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function postBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
if (!check.canPost) {
setError("Resolve stock shortages before posting this bundle.")
return
}
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
const shortageIssues = bundle && bundle.status === "Draft"
? bundle.lines
.filter((line) => line.includeInBundle)
.filter((line) => Number(line.qty || 0) > 0)
.map((line) => ({
bundleSaleLineId: line.bundleSaleLineId,
itemId: line.itemId,
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
warehouseId: line.warehouseId,
requestedQty: line.qty,
}))
: []
async function cancelBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-4" />
Print
</Link>
{canEdit ? (
editing ? (
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
<Save className="size-4" />
Save
</Button>
) : (
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
<Edit className="size-4" />
Edit
</Button>
)
) : null}
{isDraft ? (
<>
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
<CheckCircle2 className="size-4" />
Post
</Button>
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
<XCircle className="size-4" />
Cancel
</Button>
</>
) : null}
</div>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Component breakdown</h2>
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
</div>
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
{editing && isDraft ? <TableHead /> : null}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, 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 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">
{editing && isDraft ? (
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
) : (
line.includeInBundle ? "Included" : "Excluded"
)}
</TableCell>
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
</TableRow>
))}
{lines.length === 0 ? (
<TableRow>
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add line to start.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</section>
<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>
)
}
@@ -1,337 +0,0 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
})
})
: [createBlankLine()]
)
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [items, templateId])
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
return
}
setSaving(true)
setSubmitError(null)
try {
const request: CreateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.createBundle(request)
toast.success("Bundle saved", res.bundleNo)
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Need stock before saving this bundle?</div>
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
</div>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Override allowed</Label>
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
{allowPriceOverride ? "Yes" : "No"}
</button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Editable component rows</h2>
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
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 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
</TableCell>
</TableRow>
))}
{lines.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add component to start.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving..." : "Save draft"}
</Button>
</div>
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -1,308 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PaginationMeta } from "@/types/common"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSalesPage() {
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cust, whRes]) => {
setCustomers(cust.items)
setWarehouses(whRes.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setError(null)
bundleApi
.listBundles({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
q: query || undefined,
customerId: customerId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(
() =>
rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
.toLowerCase()
.includes(query.toLowerCase())
return matchesStatus && matchesQuery
}) ?? [],
[rows, query, status]
)
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Sales</h1>
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Bundle
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by bundle, code, or customer"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Bundle</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
{row.bundleNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right 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="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="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>
)
}
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation"
export default function BundleRegisterPage() {
redirect("/dashboard/sales/bundles")
}
@@ -1,27 +0,0 @@
"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>
)
}
@@ -0,0 +1,318 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Combobox } from "@/components/ui/combobox"
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 { errorMessage } from "@/lib/error-map"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({
key,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
export default function BundleTemplatesPage() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function refreshTemplates() {
const res = await bundleApi.listTemplates({ pageSize: 50 })
setTemplates(res.items)
}
useEffect(() => {
Promise.all([
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
])
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine("line-1", defaultWarehouseId)])
await refreshTemplates()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items]
)
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function resetForm() {
setTemplateName("")
setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)])
}
const componentSubtotal = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm()
await refreshTemplates()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
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" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Templates</h1>
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div>
</div>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"}
</Button>
</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 border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">Bundle details</h2>
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Template name</Label>
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger className="h-9 text-sm">
<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 className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Component items</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add component
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Include</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end border-t border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div>
</section>
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Bundle templates</h3>
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p>
</div>
<Button variant="outline" size="sm" onClick={refreshTemplates}>
Refresh
</Button>
</div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Template</TableHead>
<TableHead className="h-12 px-4 text-sm">Description</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.length > 0 ? (
templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5">
<div className="font-medium">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">
No bundle templates yet — save one above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
}
@@ -9,7 +9,7 @@ import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndPreview } from "@/types/sales" import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -150,7 +150,7 @@ export default function SalesDayEndPage() {
))} ))}
{preview.draftBundleSalesBlockingClose.map((b) => ( {preview.draftBundleSalesBlockingClose.map((b) => (
<li key={`bundle-${b.bundleSaleId}`}> <li key={`bundle-${b.bundleSaleId}`}>
<Link href={`/dashboard/sales/bundles/${b.bundleSaleId}`} className="underline">{b.bundleNo}</Link> — {b.grandTotal.toFixed(2)} {b.bundleNo} — {b.grandTotal.toFixed(2)}
</li> </li>
))} ))}
</ul> </ul>
@@ -159,7 +159,9 @@ export default function SalesDayEndPage() {
) : ( ) : (
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
{preview.slipCount === 0 && preview.bundleCount === 0 {preview.slipCount === 0 && preview.bundleCount === 0
? "No sales slips or bundle sales for this cashier on this date." ? preview.invoices.invoiceCount > 0
? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).`
: "No sales slips or bundle sales for this cashier on this date."
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`} : `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
</p> </p>
)} )}
@@ -171,6 +173,8 @@ export default function SalesDayEndPage() {
<Stat label="Grand total" value={preview.grandTotal} emphasize /> <Stat label="Grand total" value={preview.grandTotal} emphasize />
</div> </div>
<InvoiceSummaryPanel invoices={preview.invoices} />
{!preview.alreadyClosed && ( {!preview.alreadyClosed && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -263,12 +267,39 @@ export default function SalesDayEndPage() {
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p> <p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
</div> </div>
)} )}
<InvoiceSummaryPanel invoices={report.invoices} />
</div> </div>
)} )}
</div> </div>
) )
} }
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
if (invoices.invoiceCount === 0) return null
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/20 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">Sales invoices (not part of this close)</h3>
<p className="text-xs text-muted-foreground">
Invoices post their own ledger entry when confirmed — shown here for visibility only.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Invoices" value={invoices.invoiceCount} isCount />
<Stat label="Subtotal" value={invoices.invoiceSubtotal} />
<Stat label="Discount" value={invoices.invoiceDiscountTotal} />
<Stat label="Tax" value={invoices.invoiceTaxTotal} />
<Stat label="Grand total" value={invoices.invoiceGrandTotal} emphasize />
</div>
<p className="text-sm text-muted-foreground">{invoices.invoiceNumbers.join(", ")}</p>
</div>
)
}
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) { function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
return ( return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3"> <div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
@@ -21,6 +21,7 @@ import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
type Line = CreateSalesInvoiceLineRequest & { key: string } type Line = CreateSalesInvoiceLineRequest & { key: string }
@@ -47,15 +48,9 @@ const blankLine = (key: string): Line => ({
parentLineId: null, parentLineId: null,
}) })
function statusClass(status: SalesInvoiceStatus) { /** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
switch (status) { function statusWord(status: SalesInvoiceStatus): string {
case "Draft": return status === "Posted" ? "approved" : status.toLowerCase()
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 SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -144,11 +139,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
salesApi.listInvoicePayments(invoiceId).then(setPayments).catch(() => setPayments([])) salesApi.listInvoicePayments(invoiceId).then(setPayments).catch(() => setPayments([]))
}, [invoice, invoiceId]) }, [invoice, invoiceId])
function handlePaid(_updated: SalesInvoice, payment: SalesInvoicePayment) { function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setInvoice((prev) => setInvoice((prev) =>
prev prev
? { ? {
...prev, ...prev,
status: newStatus,
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount }, totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
} }
: prev : prev
@@ -310,8 +306,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<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> <SalesInvoiceStatusBadge status={invoice.status} />
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", statusClass(invoice.status))}>{invoice.status}</span> {invoice.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={invoice.totals.paidAmount} balanceAmount={invoice.totals.balanceAmount} />
)}
<span>Type: {invoice.invoiceType}</span> <span>Type: {invoice.invoiceType}</span>
<span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span> <span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span>
</div> </div>
@@ -630,7 +628,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</> </>
) : ( ) : (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>This invoice is {invoice.status.toLowerCase()} and cannot be edited.</span> <span>This invoice is {statusWord(invoice.status)} and cannot be edited.</span>
{invoice.status === "Posted" ? ( {invoice.status === "Posted" ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
@@ -1,21 +1,23 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge" import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { bundleApi } from "@/lib/api/bundles"
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"
@@ -23,15 +25,40 @@ import { warehousesApi } from "@/lib/api/warehouses"
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, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales" import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
type Line = CreateSalesInvoiceLineRequest & { key: string } // bundleGroupId/bundleName/bundleQty/bundleBaseQty are frontend-only display metadata (never sent
type ActiveFocScheme = { // to the API — submit() maps each line to CreateSalesInvoiceLineRequest field-by-field) that let
id: number // component lines added from the same bundle render collapsed into one row, with a single "how
slipNo: string // many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle
// qty from the template so re-scaling on a qty change is exact, not compounding).
type Line = CreateSalesInvoiceLineRequest & {
key: string
bundleGroupId?: string
bundleName?: string
bundleQty?: number
bundleBaseQty?: number
}
type DisplayRow =
| { type: "line"; line: Line }
| { type: "bundle"; groupId: string; bundleName: string; lines: Line[] }
function lineTotals(line: Line) {
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const discount =
line.discountMode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
return { gross, discount, net: Math.max(0, gross - discount) }
}
/** One active Free Issue record's own qty:freeQty ratio, keyed by item — the "Buy `qty` Get
* `freeQty`" deal a line's Free column auto-fills from once its item/qty match. */
type FreeIssueScheme = {
itemId: number
qty: number
freeQty: number
schemeLabel: string schemeLabel: string
warehouseName: string
productLabel: string
} }
const blankLine = (key: string): Line => ({ const blankLine = (key: string): Line => ({
@@ -57,8 +84,13 @@ const lkr = new Intl.NumberFormat("en-LK", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
export default function NewSalesInvoicePage() { function NewSalesInvoiceContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0)
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[]>([])
@@ -67,7 +99,8 @@ export default function NewSalesInvoicePage() {
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C") const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
const [lines, setLines] = useState<Line[]>([blankLine("line-1")]) const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [activeFocSchemes, setActiveFocSchemes] = useState<ActiveFocScheme[]>([]) const [freeIssueSchemes, setFreeIssueSchemes] = useState<FreeIssueScheme[]>([])
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -79,12 +112,14 @@ export default function NewSalesInvoicePage() {
uomsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 }),
salesApi.listFreeIssues({ pageSize: 20 }), salesApi.listFreeIssues({ pageSize: 20 }),
bundleApi.listTemplates({ pageSize: 200 }),
]) ])
.then(([cust, itemRes, uomRes, whRes, freeIssueRes]) => { .then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes]) => {
setCustomers(cust.items) setCustomers(cust.items)
setItems(itemRes.items) setItems(itemRes.items)
setUoms(uomRes.items) setUoms(uomRes.items)
setWarehouses(whRes.items) setWarehouses(whRes.items)
setBundleTemplates(templateRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
setCustomerId(cust.items[0]?.customerId ?? null) setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(defaultWarehouseId) setWarehouseId(defaultWarehouseId)
@@ -96,21 +131,17 @@ export default function NewSalesInvoicePage() {
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
}, },
]) ])
setActiveFocSchemes( // Cancelled Free Issues don't represent a live deal — everything else (Draft/Posted)
freeIssueRes.items.flatMap((issue) => { // does. First match per item wins (listFreeIssues returns newest first).
if (!issue.itemId) return [] setFreeIssueSchemes(
const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId) freeIssueRes.items
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId) .filter((issue) => issue.status !== "Cancelled" && issue.itemId && issue.qty > 0 && issue.freeQty > 0)
return [ .map((issue) => ({
{ itemId: issue.itemId,
id: issue.salesSlipId, qty: issue.qty,
slipNo: issue.slipNo, freeQty: issue.freeQty,
schemeLabel: issue.schemeLabel, schemeLabel: issue.schemeLabel,
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`, })),
productLabel: `${item?.sku ?? issue.itemSku} - ${item?.name ?? issue.itemName}`,
},
]
}),
) )
}) })
.catch((err) => setSubmitError(errorMessage(err))) .catch((err) => setSubmitError(errorMessage(err)))
@@ -126,14 +157,49 @@ export default function NewSalesInvoicePage() {
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId }))) setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
} }
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
}
// Tiered, not continuous: a "Buy 10 Get 2" scheme only pays out in whole multiples of its
// own qty (10 units -> 2 free, 25 units -> still 2, 30 units -> 4) — matches how the Free
// Issue slip itself frames the deal ("Buy {qty} Get {freeQty}"), not a per-unit ratio.
function suggestedFreeQty(itemId: number, qty: number): number {
const scheme = freeIssueSchemeFor(itemId)
if (!scheme || !qty) return 0
return Math.floor(qty / scheme.qty) * scheme.freeQty
}
function selectItem(key: string, itemId: number) { function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId) const qty = Number(lines.find((l) => l.key === key)?.qty ?? 0)
updateLine(key, { updateLine(key, {
itemId, itemId,
unitPrice: getSuggestedUnitPrice(items, itemId), unitPrice: getSuggestedUnitPrice(items, itemId),
freeQty: suggestedFreeQty(itemId, qty),
}) })
} }
function updateQty(key: string, itemId: number, qty: number) {
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
}
// The per-line item search doubles as bundle search: item values are prefixed "item:" and
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
const itemOptions = useMemo(
() => items.map((i) => ({ value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}` })),
[items],
)
const bundleTemplateOptions = useMemo(
() => bundleTemplates.map((t) => ({ value: `bundle:${t.bundleSaleTemplateId}`, label: `Bundle: ${t.templateCode} — ${t.templateName}` })),
[bundleTemplates],
)
const lineComboOptions = useMemo(
() => [...itemOptions, ...bundleTemplateOptions],
[itemOptions, bundleTemplateOptions],
)
function addLine() { function addLine() {
setLines((prev) => [ setLines((prev) => [
...prev, ...prev,
@@ -144,10 +210,114 @@ export default function NewSalesInvoicePage() {
]) ])
} }
// A bundle template isn't a stock item itself — it has no itemId of its own — so "adding" one
// expands it into its real component lines (each with its own itemId/qty/price) rather than a
// single pseudo-item row. That's what keeps stock and GL postings correct per component.
// When picked from a specific line's search box (replaceLineKey set), that line is dropped if
// it was still blank — otherwise it's left in place and the bundle's lines are appended after it.
async function addBundle(bundleSaleTemplateId: number, replaceLineKey?: string) {
try {
const template = await bundleApi.getTemplate(bundleSaleTemplateId)
const included = template.lines.filter((l) => l.includeInBundle)
if (included.length === 0) {
toast.error("Bundle has no components", `${template.templateName} has no included items to add.`)
return
}
const groupId = `bundle-${bundleSaleTemplateId}-${bundleGroupSeqRef.current++}`
const newLines = included.map((l, idx) => ({
...blankLine(`${groupId}-${l.bundleSaleTemplateLineId || idx}`),
itemId: l.itemId,
warehouseId: warehouseId ?? l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
freeQty: suggestedFreeQty(l.itemId, l.qty),
bundleGroupId: groupId,
bundleName: template.templateName,
bundleQty: 1,
bundleBaseQty: l.qty,
}))
setLines((prev) => [
...prev.filter((line) => line.key !== replaceLineKey || line.itemId !== 0),
...newLines,
])
toast.success("Bundle added", `${template.templateName} — ${included.length} line(s) added.`)
} catch (err) {
toast.error("Could not add bundle", errorMessage(err))
}
}
// Rescales every component line in the group from its per-bundle base qty (never from the
// already-scaled current qty) so repeated edits stay exact instead of compounding.
function updateBundleQty(groupId: string, rawBundleQty: number) {
const bundleQty = Math.max(1, Math.floor(rawBundleQty) || 1)
setLines((prev) =>
prev.map((line) => {
if (line.bundleGroupId !== groupId) return line
const baseQty = line.bundleBaseQty ?? line.qty
const qty = baseQty * bundleQty
return { ...line, bundleQty, qty, freeQty: suggestedFreeQty(line.itemId, qty) }
}),
)
}
// Combined handler for each line's item/bundle search box — routes to the right action based
// on the "item:"/"bundle:" value prefix (see lineComboOptions above).
function selectLineOption(key: string, value: string | null) {
if (!value) return
const sep = value.indexOf(":")
const kind = value.slice(0, sep)
const id = Number(value.slice(sep + 1))
if (kind === "bundle") {
addBundle(id, key)
} else {
selectItem(key, id)
}
}
// Deep-linked from the Bundle Templates page's "Sell" button (?bundleTemplateId=...) — applies
// once masters have loaded, and only once even if this effect re-runs.
useEffect(() => {
if (loading || !bundleTemplateIdFromQuery) return
if (appliedBundleRef.current === bundleTemplateIdFromQuery) return
appliedBundleRef.current = bundleTemplateIdFromQuery
addBundle(Number(bundleTemplateIdFromQuery))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, bundleTemplateIdFromQuery])
function removeLine(key: string) { function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
} }
function removeBundleGroup(groupId: string) {
setLines((prev) => {
const next = prev.filter((line) => line.bundleGroupId !== groupId)
return next.length === 0 ? [{ ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }] : next
})
}
// Groups consecutive component lines added from the same bundle into one display row, so the
// table shows "Bundle: X" once instead of one row per stock item inside it.
const displayRows = useMemo<DisplayRow[]>(() => {
const rows: DisplayRow[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (line.bundleGroupId) {
const groupId = line.bundleGroupId
const groupLines: Line[] = []
while (i < lines.length && lines[i].bundleGroupId === groupId) {
groupLines.push(lines[i])
i++
}
rows.push({ type: "bundle", groupId, bundleName: groupLines[0].bundleName ?? "Bundle", lines: groupLines })
} else {
rows.push({ type: "line", line })
i++
}
}
return rows
}, [lines])
const grossTotal = useMemo( const grossTotal = useMemo(
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0), () => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
[lines], [lines],
@@ -218,262 +388,286 @@ export default function NewSalesInvoicePage() {
} }
} }
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <div>
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <h1 className="text-2xl font-bold text-foreground">New Sale</h1>
<ArrowLeft className="size-4" /> <p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Create & open invoice"}
</Button>
</div> </div>
</div> </div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null} {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> {loading && <Skeleton className="h-12 w-full" />}
<h2 className="text-sm font-semibold">Invoice header</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> {!loading && (
<div className="space-y-1.5"> <>
<Label className="text-xs">Customer</Label> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}> <div className="flex flex-col gap-2">
<SelectTrigger className="h-9 text-sm"> <Label className="text-base">Customer</Label>
<SelectValue placeholder="Select customer" /> <Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
</SelectTrigger> <SelectTrigger className="h-12! w-full text-base">
<SelectContent> <SelectValue placeholder="Select customer" />
{customers.map((c) => ( </SelectTrigger>
<SelectItem key={c.customerId} value={String(c.customerId)}> <SelectContent>
{c.customerCode} - {c.displayName ?? c.name} {customers.map((c) => (
</SelectItem> <SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
))} {c.customerCode} — {c.displayName ?? c.name}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
<div className="space-y-1.5"> </Select>
<Label className="text-xs">Warehouse</Label> </div>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm"> <div className="flex flex-col gap-2">
<SelectValue placeholder="Select warehouse" /> <Label className="text-base">Warehouse</Label>
</SelectTrigger> <Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectContent> <SelectTrigger className="h-12! w-full text-base">
{warehouses.map((w) => ( <SelectValue placeholder="Select warehouse" />
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}> </SelectTrigger>
{w.code} - {w.name} <SelectContent>
</SelectItem> {warehouses.map((w) => (
))} <SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
</SelectContent> {w.code} — {w.name}
</Select> </SelectItem>
</div> ))}
<div className="space-y-1.5"> </SelectContent>
<Label className="text-xs">Invoice type</Label> </Select>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}> </div>
<SelectTrigger className="h-9 text-sm">
<SelectValue /> <div className="flex flex-col gap-2">
</SelectTrigger> <Label className="text-base">Invoice type</Label>
<SelectContent> <Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectItem value="B2B">B2B</SelectItem> <SelectTrigger className="h-12! w-full text-base">
<SelectItem value="B2C">B2C</SelectItem> <SelectValue />
<SelectItem value="Cash">Cash</SelectItem> </SelectTrigger>
<SelectItem value="Credit">Credit</SelectItem> <SelectContent>
</SelectContent> <SelectItem value="B2B" className="text-base">B2B</SelectItem>
</Select> <SelectItem value="B2C" className="text-base">B2C</SelectItem>
</div> <SelectItem value="Cash" className="text-base">Cash</SelectItem>
<div className="space-y-1.5"> <SelectItem value="Credit" className="text-base">Credit</SelectItem>
<Label className="text-xs">Invoice summary</Label> </SelectContent>
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground"> </Select>
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
Draft
</Badge>
<span>{lkr.format(payableTotal)}</span>
</div> </div>
</div> </div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between border-b border-border px-4 py-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-sm font-semibold">Invoice lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}> <div className="flex flex-wrap items-center gap-2">
<Plus className="size-4" /> Add line <Button type="button" variant="outline" onClick={addLine}>
</Button> <Plus className="size-5" />
</div> Add line
<div className="overflow-x-auto"> </Button>
<Table className="text-sm"> </div>
<TableHeader> </div>
<TableRow>
<TableHead className="h-12 px-4 text-sm">#</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => {
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const lineDiscount =
line.discountMode === "Amount"
? Number(line.discountAmount || 0)
: lineGross * (Number(line.discountPct || 0) / 100)
const lineNet = Math.max(0, lineGross - lineDiscount)
return ( <div className="overflow-x-auto">
<TableRow key={line.key} className="align-middle"> <Table className="text-sm">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell> <TableHeader>
<TableCell className="px-4 py-2 min-w-64"> <TableRow>
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}> <TableHead className="h-12 w-10 px-3 text-sm">#</TableHead>
<SelectTrigger className="h-9 text-sm"> <TableHead className="h-12 min-w-72 px-3 text-sm">Item</TableHead>
<SelectValue placeholder="Select item" /> <TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
</SelectTrigger> <TableHead className="h-12 px-3 text-sm text-right">Qty</TableHead>
<SelectContent> <TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>
{items.map((candidate) => ( <TableHead className="h-12 px-3 text-sm text-right">Unit price</TableHead>
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}> <TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
{candidate.sku} - {candidate.name} <TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
</SelectItem> <TableHead className="h-12 w-10 px-3" />
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow> </TableRow>
) </TableHeader>
})} <TableBody>
</TableBody> {displayRows.map((row, idx) => {
</Table> if (row.type === "bundle") {
</div> const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
</section> const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<TableRow key={row.groupId}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="font-medium">Bundle: {row.bundleName}</div>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
</li>
))}
</ul>
</TableCell>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="1"
step="1"
value={bundleQty}
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
className="h-11 text-right text-sm tabular-nums"
aria-label="Bundle quantity"
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
{bundleFreeQty || "—"}
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
}
<section className="flex justify-end"> const line = row.line
{/* <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> const { net: lineNet } = lineTotals(line)
<h3 className="text-sm font-semibold">Active FOC schemes</h3> const scheme = freeIssueSchemeFor(line.itemId)
<ul className="mt-3 space-y-2 text-sm">
{activeFocSchemes.length > 0 ? (
activeFocSchemes.map((scheme) => (
<li key={scheme.id} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{scheme.schemeLabel}</span>
<span className="text-xs text-muted-foreground">{scheme.slipNo}</span>
</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{scheme.productLabel}</span>
<span>{scheme.warehouseName}</span>
</div>
</li>
))
) : (
<li className="rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
No active free-issue schemes found.
</li>
)}
</ul>
</div> */}
<dl className="w-full max-w-sm rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]"> return (
{[ <TableRow key={line.key}>
["Gross", lkr.format(grossTotal)], <TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
["Discount", `-${lkr.format(discountTotal)}`], <TableCell className="px-3 py-3 align-top">
["Net", lkr.format(netTotal)], <Combobox
["Tax", lkr.format(taxTotal)], items={lineComboOptions}
].map(([k, v]) => ( value={line.itemId ? `item:${line.itemId}` : null}
<div key={k} className="flex items-center justify-between py-1.5"> onValueChange={(v) => selectLineOption(line.key, v)}
<dt className="text-muted-foreground">{k}</dt> placeholder="Search item or bundle…"
<dd className="font-mono tabular-nums">{v}</dd> className="h-11 text-sm"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-right text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 text-right text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</p>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(lineNet)}</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div> </div>
))}
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
<dt className="font-semibold">Payable</dt>
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
</div>
</dl>
</section>
<div className="flex justify-end gap-3"> <div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <div className="flex items-baseline gap-2">
Cancel <span className="text-sm text-muted-foreground">Gross</span>
</Link> <span className="tabular-nums">{lkr.format(grossTotal)}</span>
<Button size="lg" onClick={submit} disabled={saving}> </div>
<Save className="size-4" /> {saving ? "Saving..." : "Save invoice"} <div className="flex items-baseline gap-2">
</Button> <span className="text-sm text-muted-foreground">Discount</span>
</div> <span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Payable</span>
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
</Button>
</div>
</>
)}
</div> </div>
) )
} }
export default function NewSalesInvoicePage() {
return (
<Suspense fallback={<Skeleton className="h-12 w-full" />}>
<NewSalesInvoiceContent />
</Suspense>
)
}
@@ -2,34 +2,30 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react" import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" 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 { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
function statusClass(status: SalesInvoiceStatus) { { value: "Draft", label: "Draft" },
switch (status) { { value: "Posted", label: "Approved" },
case "Draft": { value: "Cancelled", label: "Cancelled" },
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 SalesInvoicesPage() { export default function SalesInvoicesPage() {
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null) const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
@@ -39,6 +35,9 @@ export default function SalesInvoicesPage() {
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -58,6 +57,34 @@ export default function SalesInvoicesPage() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [page, status]) }, [page, status])
async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId)
try {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.invoiceNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
setConfirmingId(null)
}
}
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setRows(
(prev) =>
prev?.map((r) =>
r.salesInvoiceId === payment.salesInvoiceId
? {
...r,
status: newStatus,
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
}
: r
) ?? prev
)
}
const visibleRows = useMemo( const visibleRows = useMemo(
() => () =>
rows?.filter((row) => rows?.filter((row) =>
@@ -100,14 +127,14 @@ export default function SalesInvoicesPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
key={t} key={t.value}
onClick={() => setStatus(t)} onClick={() => setStatus(t.value)}
className={cn( className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors", "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" status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)} )}
> >
{t} {t.label}
</button> </button>
))} ))}
</div> </div>
@@ -158,7 +185,7 @@ export default function SalesInvoicesPage() {
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead> <TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead> <TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead> <TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead> <TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -177,18 +204,49 @@ export default function SalesInvoicesPage() {
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5"> <TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}> <div className="flex flex-col items-start gap-1">
{row.status} <SalesInvoiceStatusBadge status={row.status} />
</Badge> {row.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5 text-right"> <TableCell className="px-4 py-3.5 text-right">
<Link <div className="flex items-center justify-end gap-1">
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} {row.status === "Draft" && (
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <Button
aria-label={`View invoice ${row.invoiceNo}`} type="button"
> variant="outline"
<Eye className="size-4" /> size="sm"
</Link> disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.invoiceNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -234,6 +292,8 @@ export default function SalesInvoicesPage() {
</> </>
)} )}
</div> </div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div> </div>
) )
} }
@@ -1,5 +1,5 @@
import Link from "next/link" import Link from "next/link"
import { FileBarChart, FileText, Lock, PackageX, ReceiptText } from "lucide-react" import { FileBarChart, FileText, LayoutList, Lock, PackageX, ReceiptText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -11,6 +11,12 @@ const sections = [
href: "/dashboard/sales/invoices", href: "/dashboard/sales/invoices",
icon: FileText, icon: FileText,
}, },
{
title: "Bundle Templates",
description: "Define reusable bundles — sell them straight from Create Sale.",
href: "/dashboard/sales/bundles/templates",
icon: LayoutList,
},
{ {
title: "Free Issues", title: "Free Issues",
description: "Promotional free-issue slips.", description: "Promotional free-issue slips.",
@@ -1,74 +0,0 @@
"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>
)
}
@@ -1,99 +0,0 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSaleSummary } from "@/types/bundles"
function statusClass(status: BundleSaleSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function BundleBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
bundleApi.listBundles({ page: 1, pageSize: 200 }).then((res) => setRows(res.items)).catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery = `${row.bundleNo} ${row.customerSnapshotName} ${row.bundleName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!rows) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle print data...</div>
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Bundle Sales</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Bundle Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current bundle sales.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bundle</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Grand</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.bundleSaleId}>
<TableCell className="font-medium">{row.bundleNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function BundleBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<BundleBatchPrintContent />
</Suspense>
)
}
@@ -27,9 +27,11 @@ import {
Inbox, Inbox,
Landmark, Landmark,
LayoutGrid, LayoutGrid,
LayoutList,
LayoutTemplate, LayoutTemplate,
LineChart, LineChart,
ListTree, ListTree,
Lock,
Menu, Menu,
Package, Package,
PackageCheck, PackageCheck,
@@ -116,8 +118,9 @@ const navItems: {
chevron: true, chevron: true,
children: [ children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, { title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes }, { title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
{ 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: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, // { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
], ],
}, },
@@ -7,7 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format" import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger" import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales" import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -17,14 +17,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
interface SalesInvoicePaymentDialogProps { interface SalesInvoicePaymentDialogProps {
invoice: SalesInvoice | null // SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
// (id/no/status/balance) is already on the summary, so a list row can open it without a
// separate getInvoice() round trip. SalesInvoice extends SalesInvoiceSummary, so the detail
// page's full invoice still satisfies this as-is.
invoice: SalesInvoiceSummary | null
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void /** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
* it started that way or this dialog auto-posted it first. */
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
} }
/** Record a customer payment against a posted invoice's balance — full or partial, into an /** Record a customer payment against an invoice's balance — full or partial, into an existing GL
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */ * cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
* invoice skips straight to payment. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) { export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("") const [glBankAccountId, setGlBankAccountId] = useState("")
@@ -64,16 +72,32 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
setSubmitting(true) setSubmitting(true)
try { try {
if (invoice.status === "Draft") {
try {
await salesApi.postInvoice(invoice.salesInvoiceId)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
return
}
}
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, { const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
amount: amountNum, amount: amountNum,
glBankAccountId: Number(glBankAccountId), glBankAccountId: Number(glBankAccountId),
reference: reference || undefined, reference: reference || undefined,
}) })
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`) const fullyPaid = amountNum >= invoice.totals.balanceAmount
onPaid(invoice, payment) toast.success(
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
)
onPaid(payment, "Posted")
onOpenChange(false) onOpenChange(false)
} catch (err) { } catch (err) {
toast.error("Could not record payment", errorMessage(err)) toast.error(
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
errorMessage(err)
)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -83,10 +107,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Pay invoice {invoice.invoiceNo}</DialogTitle> <DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span> Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
</p> </p>
@@ -134,7 +159,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={submitting}> <Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : "Record payment"} {submitting
? "Recording…"
: invoice.status === "Draft"
? "Confirm & record payment"
: "Record payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -0,0 +1,57 @@
import { Badge } from "@/components/ui/badge"
import { SalesInvoiceStatus } from "@/types/sales"
// Same fixed-width convention as components/receiving/status-badges.tsx's GRN badges — every
// pill is the same size regardless of label length ("Draft" vs "Approved" vs "Partial").
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
function statusClass(status: SalesInvoiceStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
}
// "Posted" reads as "Approved" everywhere in the UI — the underlying SalesInvoiceStatus value
// stays "Posted" (that's what the API sends/expects), only the displayed label changes.
const statusLabel: Record<SalesInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function SalesInvoiceStatusBadge({ status }: { status: SalesInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${statusClass(status)}`}>
{statusLabel[status]}
</Badge>
)
}
export type SalesPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function salesPaymentStatus(paidAmount: number, balanceAmount: number): SalesPaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function paymentClass(status: SalesPaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const paymentLabel: Record<SalesPaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function SalesPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = salesPaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
+36 -1
View File
@@ -47,7 +47,36 @@ export function readCsrfToken(): string | null {
return match ? decodeURIComponent(match[1]) : null return match ? decodeURIComponent(match[1]) : null
} }
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> { // erp_at is short-lived; erp_rt (a separate, refresh-route-scoped cookie) outlives it. Before
// this, an expired erp_at surfaced as a bare 401 on whatever the user happened to click next
// (confirming an invoice, recording a payment, ...) with no attempt to renew the session — this
// dedupes concurrent 401s onto one /auth/refresh-token call so every caller gets one retry.
let refreshPromise: Promise<boolean> | null = null
function refreshSession(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = fetch(`${API_BASE}/auth/refresh-token`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: "{}",
})
.then((res) => res.ok)
.catch(() => false)
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
function redirectToLogin() {
if (typeof window === "undefined") return
const next = `${window.location.pathname}${window.location.search}`
window.location.href = `/login?next=${encodeURIComponent(next)}`
}
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null const csrfToken = csrf ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the // Multipart uploads (attendance files, staff documents) pass a FormData body — the
@@ -70,6 +99,12 @@ async function rawRequest(path: string, options: RequestOptions = {}): Promise<R
body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined, body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined,
}) })
if (response.status === 401 && !isRetry && !path.startsWith("/auth/")) {
const refreshed = await refreshSession()
if (refreshed) return rawRequest(path, options, true)
redirectToLogin()
}
if (!response.ok) { if (!response.ok) {
let problem: ProblemDetails let problem: ProblemDetails
try { try {
+5
View File
@@ -7,6 +7,7 @@ import {
BundleSaleTemplate, BundleSaleTemplate,
BundleSaleTemplateSummary, BundleSaleTemplateSummary,
CreateBundleSaleRequest, CreateBundleSaleRequest,
CreateBundleSaleTemplateRequest,
UpdateBundleSaleRequest, UpdateBundleSaleRequest,
} from "@/types/bundles" } from "@/types/bundles"
@@ -46,4 +47,8 @@ export const bundleApi = {
getTemplate(bundleSaleTemplateId: number): Promise<BundleSaleTemplate> { getTemplate(bundleSaleTemplateId: number): Promise<BundleSaleTemplate> {
return apiRequest<BundleSaleTemplate>(`/bundle-sales/templates/${bundleSaleTemplateId}`) return apiRequest<BundleSaleTemplate>(`/bundle-sales/templates/${bundleSaleTemplateId}`)
}, },
createTemplate(request: CreateBundleSaleTemplateRequest): Promise<BundleSaleTemplate> {
return apiRequest<BundleSaleTemplate>("/bundle-sales/templates", { method: "POST", body: request })
},
} }
+2 -2
View File
@@ -2,5 +2,5 @@ import { ItemListItem } from "@/types/master-data"
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null { export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
if (!itemId) return null if (!itemId) return null
return items.find((item) => item.itemId === itemId)?.salePrice ?? null return items.find((item) => item.itemId === itemId)?.sellingFlatPrice ?? null
} }
+15
View File
@@ -34,6 +34,21 @@ export interface BundleSaleTemplate {
lines: BundleSaleTemplateLine[] lines: BundleSaleTemplateLine[]
} }
export interface CreateBundleSaleTemplateLineRequest {
itemId: number
warehouseId: number
qty: number
unitPrice: number
includeInBundle: boolean
sortOrder: number
}
export interface CreateBundleSaleTemplateRequest {
templateName: string
description?: string | null
lines: CreateBundleSaleTemplateLineRequest[]
}
export interface BundleSaleLine { export interface BundleSaleLine {
bundleSaleLineId: number bundleSaleLineId: number
itemId: number itemId: number
+9 -3
View File
@@ -50,7 +50,9 @@ export interface ItemListItem extends ItemContent {
warrantyPeriodMonths: WarrantyPeriodMonths | null warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */ /** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null sellingMinPrice: number | null
sellingMaxPrice: number | null
sellingFlatPrice: number | null
status: EntityStatus status: EntityStatus
} }
@@ -85,7 +87,9 @@ export interface Item extends ItemContent {
warrantyPeriodMonths: WarrantyPeriodMonths | null warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */ /** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null sellingMinPrice: number | null
sellingMaxPrice: number | null
sellingFlatPrice: number | null
status: EntityStatus status: EntityStatus
reorder: ItemReorderSetting[] reorder: ItemReorderSetting[]
createdAt: string createdAt: string
@@ -112,7 +116,9 @@ export interface CreateItemRequest {
warrantyPeriodMonths?: WarrantyPeriodMonths | null warrantyPeriodMonths?: WarrantyPeriodMonths | null
taxClass?: string | null taxClass?: string | null
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */ /** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
salePrice?: number | null sellingMinPrice?: number | null
sellingMaxPrice?: number | null
sellingFlatPrice?: number | null
/** /**
* Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is * Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is
* derived server-side and is deliberately not accepted here. * derived server-side and is deliberately not accepted here.
+16 -1
View File
@@ -299,7 +299,7 @@ export interface SalesFreeIssueRewardOption {
itemId: number itemId: number
sku: string sku: string
name: string name: string
salePrice: number | null sellingFlatPrice: number | null
} }
export interface SalesFreeIssueSuggestionLine { export interface SalesFreeIssueSuggestionLine {
@@ -399,6 +399,19 @@ export interface SalesDayEndItemLine {
revenue: number revenue: number
} }
/** Read-only visibility into that cashier's Sales Invoices for the date — informational only.
* Invoices post their own GL entry on Confirm and have no DayEndId, so they're never locked
* against a close: this is computed live on every read, unlike the frozen Slip/Bundle totals
* alongside it. */
export interface SalesDayEndInvoiceSummary {
invoiceCount: number
invoiceSubtotal: number
invoiceDiscountTotal: number
invoiceTaxTotal: number
invoiceGrandTotal: number
invoiceNumbers: string[]
}
export interface SalesDayEnd { export interface SalesDayEnd {
salesDayEndId: number salesDayEndId: number
docNo: string docNo: string
@@ -418,6 +431,7 @@ export interface SalesDayEnd {
slipNumbers: string[] slipNumbers: string[]
bundleNumbers: string[] bundleNumbers: string[]
itemBreakdown: SalesDayEndItemLine[] itemBreakdown: SalesDayEndItemLine[]
invoices: SalesDayEndInvoiceSummary
} }
export interface SalesDayEndSummary { export interface SalesDayEndSummary {
@@ -458,6 +472,7 @@ export interface SalesDayEndPreview {
bundleNumbers: string[] bundleNumbers: string[]
draftSlipsBlockingClose: DraftSlipBlockingClose[] draftSlipsBlockingClose: DraftSlipBlockingClose[]
draftBundleSalesBlockingClose: DraftBundleBlockingClose[] draftBundleSalesBlockingClose: DraftBundleBlockingClose[]
invoices: SalesDayEndInvoiceSummary
} }
export interface CreateSalesDayEndRequest { export interface CreateSalesDayEndRequest {