add day-end and change item model
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'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 "Add bundle" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user