Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e13ec0f109 | |||
| 39ad424d21 |
@@ -52,13 +52,6 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/preview-totals")]
|
||||
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var result = await service.PreviewTotalsAsync(id, request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/versions")]
|
||||
public async Task<IActionResult> GetVersions(Guid id)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Models;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
@@ -10,14 +9,7 @@ public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
|
||||
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null);
|
||||
|
||||
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||
|
||||
@@ -38,10 +30,7 @@ public record InvoiceItemResponse(
|
||||
int? OrderedQuantity,
|
||||
int Quantity,
|
||||
decimal UnitPrice,
|
||||
bool IsRemoved,
|
||||
bool IsFree = false,
|
||||
Guid? SchemeId = null,
|
||||
string? SchemeName = null);
|
||||
bool IsRemoved);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
@@ -60,7 +49,6 @@ public record InvoiceResponse(
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceItemResponse> Items,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
List<InvoiceItemResponse> Items);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,7 +9,6 @@ namespace Distribution.Features.Invoice;
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
SchemeService schemeService,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
@@ -27,53 +25,20 @@ public class InvoiceService(
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList());
|
||||
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||
// scheme rules ignore them.
|
||||
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||
.ToList();
|
||||
|
||||
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||
// and at print/finalize. The redemption amount is updated with it.
|
||||
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
|
||||
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var lines = items.ToList();
|
||||
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||
var billable = BillableTotal(lines);
|
||||
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||
var billable = BillableTotal(items);
|
||||
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
|
||||
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||
.ToList();
|
||||
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||
var chosen = request.DiscountSchemeIds is null
|
||||
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||
var discount = Math.Min(chosen, billable);
|
||||
|
||||
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||
}
|
||||
|
||||
// Immutable copy of the invoice lines as they stand right now.
|
||||
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||
new()
|
||||
@@ -118,17 +83,13 @@ public class InvoiceService(
|
||||
FK_ShopPOItemId = i.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsFree = i.IsFree,
|
||||
FK_SchemeId = i.FK_SchemeId,
|
||||
SchemeName = i.SchemeName
|
||||
UnitPrice = i.UnitPrice
|
||||
}).ToList();
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||
return invoice is null ? null : ToResponse(invoice);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||
@@ -211,12 +172,7 @@ public class InvoiceService(
|
||||
ValidateQuantity(item, edit.Quantity);
|
||||
item.Quantity = edit.Quantity;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price)
|
||||
{
|
||||
if (item.IsFree && price != 0m)
|
||||
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||
item.UnitPrice = price;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price) item.UnitPrice = price;
|
||||
}
|
||||
else if (edit.ItemId is Guid itemId)
|
||||
{
|
||||
@@ -245,10 +201,7 @@ public class InvoiceService(
|
||||
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||
}
|
||||
|
||||
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||
await ApplyTotalsAsync(invoice, allLines);
|
||||
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -260,7 +213,7 @@ public class InvoiceService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||
|
||||
@@ -10,8 +10,6 @@ public interface ISchemeRepository
|
||||
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("suggest")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
|
||||
{
|
||||
var result = await service.SuggestAsync(request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/spend")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetSpend(Guid id)
|
||||
|
||||
@@ -94,13 +94,7 @@ public record SchemeEligibilityResponse(
|
||||
string? Reason,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
decimal? FreeQty,
|
||||
string? FreeItemName = null);
|
||||
|
||||
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||
|
||||
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||
decimal? FreeQty);
|
||||
|
||||
public record SchemeRedemptionResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -49,18 +49,6 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||
await context.SchemeRedemptions.AddAsync(entity);
|
||||
|
||||
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
|
||||
{
|
||||
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
|
||||
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
|
||||
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
|
||||
list.Add(added);
|
||||
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
|
||||
}
|
||||
|
||||
public void RemoveRedemption(Models.SchemeRedemption entity) =>
|
||||
context.SchemeRedemptions.Remove(entity);
|
||||
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Store;
|
||||
@@ -16,8 +14,6 @@ public class SchemeService(
|
||||
IItemRepository itemRepo,
|
||||
IStoreRepository storeRepo,
|
||||
IStockRepository stockRepo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -265,7 +261,12 @@ public class SchemeService(
|
||||
|
||||
foreach (var scheme in candidates)
|
||||
{
|
||||
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
||||
continue;
|
||||
|
||||
result.Add(EvaluateRule(scheme, items));
|
||||
@@ -274,328 +275,6 @@ public class SchemeService(
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
|
||||
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
|
||||
/// </summary>
|
||||
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
|
||||
{
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
var lines = new List<ShopPOItem>();
|
||||
foreach (var l in request.Items.Where(i => !i.IsFree))
|
||||
{
|
||||
var price = l.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? 0m;
|
||||
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
|
||||
}
|
||||
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
|
||||
var result = new List<SchemeEligibilityResponse>();
|
||||
|
||||
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
|
||||
{
|
||||
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
var relevant = eval.Eligible || scheme.RuleType switch
|
||||
{
|
||||
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
|
||||
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
|
||||
_ => false
|
||||
};
|
||||
if (!relevant) continue;
|
||||
|
||||
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
|
||||
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
|
||||
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
|
||||
/// </summary>
|
||||
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsFree).ToList();
|
||||
if (freeLines.Count == 0) return;
|
||||
|
||||
if (freeLines.Any(l => l.FK_SchemeId is null))
|
||||
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
|
||||
if (freeLines.Any(l => l.Quantity <= 0))
|
||||
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(group.Key);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
|
||||
if (!IsInScope(scheme, store.Segment, regionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
|
||||
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
|
||||
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
if (qty > eval.FreeQty.Value)
|
||||
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
|
||||
|
||||
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
|
||||
if (available < qty)
|
||||
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
|
||||
|
||||
foreach (var l in group)
|
||||
{
|
||||
l.UnitPrice = 0m;
|
||||
l.SchemeName = scheme.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
|
||||
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
|
||||
/// </summary>
|
||||
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is not { Count: > 0 }) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
|
||||
var scheme = await repo.GetByIdAsync(schemeIds[0]);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("The selected discount scheme no longer exists.");
|
||||
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
|
||||
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
|
||||
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
|
||||
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
|
||||
if (persist) r.DiscountAmount = amount;
|
||||
total += amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
|
||||
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
|
||||
{
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
|
||||
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
|
||||
/// only the paid ones count toward the scheme rules.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
|
||||
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
|
||||
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
|
||||
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
|
||||
/// </summary>
|
||||
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
|
||||
if (freeLines.Count == 0) return false;
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
|
||||
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var changed = false;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var remaining = entitled[group.Key];
|
||||
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
|
||||
{
|
||||
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
|
||||
|
||||
if (remaining == 0) line.IsRemoved = true;
|
||||
else line.Quantity = remaining;
|
||||
remaining = 0;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
|
||||
if (redemption is not null)
|
||||
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
|
||||
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
|
||||
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
|
||||
/// </summary>
|
||||
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is null) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
|
||||
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
|
||||
repo.RemoveRedemption(r);
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
|
||||
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
|
||||
|
||||
foreach (var id in added)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
|
||||
}
|
||||
|
||||
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
|
||||
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
|
||||
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
|
||||
.Where(r => r.DiscountAmount.HasValue)
|
||||
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
|
||||
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
|
||||
/// longer qualifies. Stages changes only — the caller owns the commit.
|
||||
/// </summary>
|
||||
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
|
||||
{
|
||||
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
|
||||
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var r in existing)
|
||||
{
|
||||
if (r.FK_FreeItemId.HasValue)
|
||||
{
|
||||
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
|
||||
}
|
||||
else if (r.DiscountAmount.HasValue)
|
||||
{
|
||||
// A null list means "leave the accepted discounts alone" (just recompute them).
|
||||
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
|
||||
{
|
||||
repo.RemoveRedemption(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
|
||||
else repo.RemoveRedemption(r);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var schemeId in discountSchemeIds ?? [])
|
||||
{
|
||||
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null })
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
DiscountAmount = eval.DiscountAmount
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var (schemeId, group) in freeBySchemes)
|
||||
{
|
||||
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
|
||||
?? throw new InvalidOperationException("Free item not found.");
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
|
||||
|
||||
if (redemption is null)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_FreeItemId = freeItem.Id,
|
||||
FreeQty = qty,
|
||||
FreeItemCostValue = freeItem.CostPrice ?? 0m
|
||||
});
|
||||
else
|
||||
{
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
redemption.FreeQty = qty;
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||
@@ -620,18 +299,14 @@ public class SchemeService(
|
||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||
|
||||
var today = DateTime.Now;
|
||||
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
||||
return (false, "Scheme is not currently active (outside its date range).");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
||||
{
|
||||
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
|
||||
// quantity, otherwise the free units inflate the multiplier.
|
||||
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
|
||||
|
||||
switch (scheme.RuleType)
|
||||
{
|
||||
case SchemeRuleType.BuyXGetY:
|
||||
@@ -739,10 +414,7 @@ public class SchemeService(
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_ItemId = freeItem.Id,
|
||||
Quantity = (int)eligibility.FreeQty.Value,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = scheme.Id,
|
||||
SchemeName = scheme.Name
|
||||
UnitPrice = 0m
|
||||
});
|
||||
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
@@ -753,27 +425,7 @@ public class SchemeService(
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (newItems.Count > 0)
|
||||
{
|
||||
await shopPORepo.AddItemsAsync(newItems);
|
||||
|
||||
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||
{
|
||||
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoiceId,
|
||||
FK_ShopPOItemId = n.Id,
|
||||
FK_ItemId = n.FK_ItemId,
|
||||
Quantity = n.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = n.FK_SchemeId,
|
||||
SchemeName = n.SchemeName
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
||||
await repo.AddRedemptionAsync(redemption);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -835,8 +487,6 @@ public class SchemeService(
|
||||
|
||||
if (freeItemId is null || freeQty is null or <= 0)
|
||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||
if (freeQty.Value != decimal.Truncate(freeQty.Value))
|
||||
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
|
||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||
throw new InvalidOperationException("Free item not found.");
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||
|
||||
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
||||
|
||||
public record ShopPOResponse(
|
||||
Guid Id,
|
||||
@@ -39,5 +34,4 @@ public record ShopPOResponse(
|
||||
DateTime? CancelledAt = null,
|
||||
string? CancelReason = null,
|
||||
string? CancelledByName = null,
|
||||
string? CancelledByRole = null,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
string? CancelledByRole = null);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -12,30 +11,9 @@ public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
SchemeService schemeService,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||
{
|
||||
if (line.IsFree)
|
||||
return new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = line.SchemeId
|
||||
};
|
||||
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||
}
|
||||
|
||||
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
@@ -44,15 +22,14 @@ public class ShopPOService(
|
||||
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||
ct);
|
||||
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
||||
p.IsActive, p.CreatedAt,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||
@@ -60,8 +37,7 @@ public class ShopPOService(
|
||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||
discounts ?? []);
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
||||
|
||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||
{
|
||||
@@ -86,7 +62,7 @@ public class ShopPOService(
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null) return null;
|
||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||
return ToResponse(po, users);
|
||||
}
|
||||
|
||||
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||
@@ -106,13 +82,19 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var item = await BuildLineAsync(line, request.StoreId);
|
||||
items.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||
items.Add(new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
var po = new Models.ShopPO
|
||||
{
|
||||
@@ -128,7 +110,6 @@ public class ShopPOService(
|
||||
await repo.AddAsync(po);
|
||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||
await repo.AddItemsAsync(items);
|
||||
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
@@ -156,14 +137,20 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||
item.FK_ShopPOId = id;
|
||||
newItems.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||
newItems.Add(new ShopPOItem
|
||||
{
|
||||
FK_ShopPOId = id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
po.TotalAmount = total;
|
||||
|
||||
@@ -171,7 +158,6 @@ public class ShopPOService(
|
||||
try
|
||||
{
|
||||
await repo.ReplaceItemsAsync(id, newItems);
|
||||
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -226,9 +212,6 @@ public class ShopPOService(
|
||||
CreatedBy = userId,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,99 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFreeIssueLines : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
// Existing free-issue lines were plain zero-priced lines; flag them from their scheme redemption.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ShopPOItems i
|
||||
JOIN SchemeRedemptions r ON r.FK_ShopPOId = i.FK_ShopPOId AND r.FK_FreeItemId = i.FK_ItemId
|
||||
JOIN Schemes s ON s.Id = r.FK_SchemeId
|
||||
SET i.IsFree = 1, i.FK_SchemeId = r.FK_SchemeId, i.SchemeName = s.Name
|
||||
WHERE i.UnitPrice = 0 AND i.IsActive = 1;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE InvoiceItems ii
|
||||
JOIN ShopPOItems pi ON ii.FK_ShopPOItemId = pi.Id
|
||||
SET ii.IsFree = pi.IsFree, ii.FK_SchemeId = pi.FK_SchemeId, ii.SchemeName = pi.SchemeName
|
||||
WHERE pi.IsFree = 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -932,27 +932,18 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_SchemeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ShopPOItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsFree")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsRemoved")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SchemeName")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -3726,27 +3717,18 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_SchemeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ShopPOId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsFree")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("QuantityFulfilled")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SchemeName")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -3766,7 +3748,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 30,
|
||||
QuantityFulfilled = 30,
|
||||
UnitPrice = 60m
|
||||
@@ -3778,7 +3759,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 24,
|
||||
QuantityFulfilled = 24,
|
||||
UnitPrice = 50m
|
||||
@@ -3790,7 +3770,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 10,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = 120m
|
||||
@@ -3802,7 +3781,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 20,
|
||||
QuantityFulfilled = 20,
|
||||
UnitPrice = 120m
|
||||
|
||||
@@ -19,10 +19,5 @@ namespace Distribution.Models
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
|
||||
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,5 @@ namespace Distribution.Models
|
||||
public int Quantity { get; set; }
|
||||
public int QuantityFulfilled { get; set; } = 0;
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>Free issue granted by a scheme (UnitPrice 0); shown in the Free Issue column.</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
/// <summary>Scheme name when it was applied, so later scheme edits don't rewrite history.</summary>
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem
|
||||
SchemeRedemption
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required
|
||||
├── FK_ShopPOId (Guid?) — nullable (see FieldInvoiceId below); the Shop PO the scheme was applied to
|
||||
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
|
||||
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||
│ entity exists yet, so this is an unconstrained Guid for now
|
||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||
@@ -87,8 +87,8 @@ SchemeRedemption
|
||||
│ later changes
|
||||
├── RedeemedAt (DateTime)
|
||||
```
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
|
||||
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
|
||||
### Item (extended)
|
||||
```
|
||||
@@ -124,10 +124,8 @@ columns/child table after the first migration had already been applied to the de
|
||||
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
||||
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
|
||||
| — | `POST /api/schemes/suggest` | Body `{ storeId, items:[{itemId, quantity, unitPrice?}] }`; scheme suggestions for a Shop PO that is not saved yet |
|
||||
| `evaluateSchemeEligibility(storeId, shopPOId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` (`orderId` is the Shop PO id) | Returns eligibility for every in-scope scheme, not just one |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
|
||||
| — | `POST /api/invoices/{id}/preview-totals` | Draft-editor preview of discount, total and free entitlement for unsaved lines |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
||||
|
||||
@@ -218,18 +216,13 @@ Use this to add/edit one bundle component after creation.
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
|
||||
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
|
||||
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/Shop PO only if: the scheme's `RegionId` is
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
|
||||
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
|
||||
whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st).
|
||||
3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
|
||||
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
|
||||
redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate.
|
||||
4. `FreeBundle` eligibility: the Shop PO must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty` (paid lines only, same as rule 3); payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
`FreeQty` must be a whole number (validated on create / `PUT .../free-goods-rule`), because
|
||||
Shop PO lines hold whole units.
|
||||
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
|
||||
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
|
||||
be picked, delivered, and stock-tracked like any other sold line.
|
||||
@@ -247,80 +240,6 @@ Use this to add/edit one bundle component after creation.
|
||||
|
||||
---
|
||||
|
||||
## Offers on the PO and Invoice (implemented)
|
||||
|
||||
Nothing is added silently: the system **suggests**, the user **accepts**, and what was accepted stays **editable**.
|
||||
|
||||
### 1. On the Shop PO (new and edit screens)
|
||||
1. While paid lines are entered, the screen calls `POST /api/schemes/suggest` (`{ storeId, items:[{itemId, quantity, unitPrice?}] }`).
|
||||
The server returns the in-scope schemes (region, segment, date) the lines qualify for, plus Buy & get free /
|
||||
Combo gift schemes the cart is part-way to (`reason` doubles as a nudge, e.g. "Requires 10 of the trigger item;
|
||||
ShopPO has 6").
|
||||
2. The **Offers & Free Issues** panel shows:
|
||||
- **Free goods** (BuyXGetY / FreeBundle): "Qualifies for N free X" with **Add free**. The accepted free line is
|
||||
editable (qty, remove) and is saved as a line with `isFree = true` and the `schemeId`.
|
||||
- **Percentage discount** (FlatPercent / SlabDiscount): "Saves Rs. X" with **Apply discount**. Only one discount
|
||||
per order. The accepted scheme ids are sent as `discountSchemeIds` on create / update-items.
|
||||
3. If a later paid-qty change shrinks what the order earns, the screen trims the free qty / drops the discount and
|
||||
says so. The server enforces the same rules on save: free qty within the entitlement, free item in stock, scheme
|
||||
active and in scope, at most one discount, order still qualifies.
|
||||
4. Saving keeps `SchemeRedemption` in step with the lines: created or resized per free line, removed with its line,
|
||||
and discount redemptions created, recomputed, or dropped if the order stops qualifying.
|
||||
5. `GET /api/shop-pos/{id}` returns `discounts[]` (scheme, amount) so the edit screen starts with the right state and
|
||||
the PO view shows a "Scheme discount" row above the total. `PO.TotalAmount` stays the full lines total.
|
||||
|
||||
### 2. On the invoice
|
||||
- The invoice copies the PO lines, including free lines (`IsFree`, `FK_SchemeId`, `SchemeName`). Free lines show in a
|
||||
**Free Issue** column (a "-" in the cell that does not apply), are listed **after all paid lines**, are zero-priced
|
||||
(price cannot be edited) and can be lowered like any line while the invoice is Draft.
|
||||
- `Invoice.TotalAmount = billable lines - DiscountAmount`. When the invoice is created from a PO it starts at the
|
||||
discounted total.
|
||||
- **Draft invoice editor** (`/sales/invoices/{id}/edit`):
|
||||
- A **Scheme discount** box lists the flat / slab discounts the current lines qualify for with **Apply discount** /
|
||||
**Remove**. This also covers a PO that was confirmed before a discount was applied. `PUT /api/invoices/{id}/items`
|
||||
accepts `discountSchemeIds` (null = leave as is).
|
||||
- `POST /api/invoices/{id}/preview-totals` (`{ items:[{itemId, quantity, unitPrice, isFree, isRemoved, schemeId}], discountSchemeIds? }`)
|
||||
returns the discount, total and per-scheme free entitlement for the unsaved lines; the editor calls it (debounced)
|
||||
so the Scheme discount row and Total update while typing. It changes nothing.
|
||||
- **Recalculation.** On every line change (add, remove, restore, re-price, quantity) and again at print and finalize,
|
||||
`InvoiceService.ApplyTotalsAsync` first reconciles free lines, then re-evaluates the discount against the current lines:
|
||||
- FlatPercent stays the set % of the current lines total; SlabDiscount can move up or down a level, or drop to 0 and
|
||||
return if the lines requalify. Free lines and struck-out lines are ignored. The redemption's `DiscountAmount` is
|
||||
updated with it, so scheme spend / ROI stay correct.
|
||||
- **Free lines only shrink automatically.** If the paid quantity that earned a free issue is lowered or removed, the free
|
||||
quantity is reduced to what is still earned (a line that earns nothing is struck out, keeping its quantity so it can be
|
||||
restored) and the PO's free-goods redemption is resized. Free quantity is never raised automatically; a person raises it,
|
||||
up to what the scheme earns.
|
||||
- The invoice page's Items table ends with **Subtotal / Scheme discount / Total**, and the printed A4 and receipt copies show
|
||||
Subtotal, Scheme discount, Total (Paid / Balance Due use the discounted total).
|
||||
- The older `POST /api/orders/{id}/apply-scheme` endpoint still works for an already-saved PO and flags the line the same way
|
||||
(and mirrors a free line onto a still-Draft invoice).
|
||||
|
||||
### 3. Scheme setup screens
|
||||
`/pricing/schemes/new`, `/pricing/schemes/{id}` (read-only) and `/pricing/schemes/{id}/edit` are full pages (no modal) built on one
|
||||
form. Rule types are shown in plain words: **Buy & get free** (BuyXGetY), **Combo gift** (FreeBundle), **Flat discount**
|
||||
(FlatPercent), **Spend more, save more** (SlabDiscount); the fields adapt to the type (product dropdowns, quantities, % and
|
||||
levels) with a live summary sentence. Required fields come first; Active, then the optional Region / Store segment last. The scheme
|
||||
name is optional (generated from the offer when blank). The rule type cannot change after creation.
|
||||
|
||||
### Data
|
||||
`ShopPOItem` and `InvoiceItem` gained `IsFree`, `FK_SchemeId` (plain Guid, no FK constraint) and a `SchemeName` snapshot. Migration
|
||||
`AddFreeIssueLines` also back-fills existing zero-priced redemption lines.
|
||||
|
||||
### Not done yet
|
||||
`SchemeRedemption.FK_InvoiceId` (`FieldInvoiceId` is still an unused placeholder); invoice-version snapshots do not record `IsFree`;
|
||||
no hint or one-click "add more free units" when the paid quantity rises; scope (date / region) is checked when a discount is first
|
||||
applied, not re-checked on later invoice edits; Slab "close to the next level" hints are not shown.
|
||||
|
||||
## Known Gaps vs. Enterprise Practice
|
||||
|
||||
Scheme code, lifecycle (Draft/Approved/Active/Expired), priority and stackable/exclusive flags;
|
||||
budget and per-store caps; triggers on product groups/brands/categories or order value; tiered
|
||||
free-goods slabs; funding source (company vs supplier — ties into SupplierClaims and GRN
|
||||
`FreeQuantity`); stock reservation for the free item (the current check only sums balances).
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
||||
|
||||
@@ -95,7 +95,6 @@ InvoiceItem — the live billing lines
|
||||
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
|
||||
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
||||
├── UnitPrice (decimal)
|
||||
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
|
||||
└── IsActive, CreatedAt — from Template
|
||||
|
||||
InvoiceVersion — immutable snapshot of the invoice
|
||||
@@ -146,8 +145,7 @@ invoice for the preview, the draft editor and the print tab:
|
||||
`*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!").
|
||||
- Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`.
|
||||
- Deleted lines are shown **struck through in every status** and excluded from all totals.
|
||||
- Scheme discount: shown as Subtotal / Scheme discount / Total (`Invoice.DiscountAmount`), re-derived from the current lines on every edit, print and finalize (see 17_promotions_schemes.md). Free-issue lines get a **Free Issue** column and come after the paid lines.
|
||||
- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date.
|
||||
- Not included (no data in the app): company/"From" block, logo, bank details, discount, tax, due date.
|
||||
|
||||
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
|
||||
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
|
||||
@@ -292,9 +290,8 @@ Draft → Printed → Finalized
|
||||
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
||||
Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled
|
||||
by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see
|
||||
[17_promotions_schemes.md](../17_promotions_schemes.md).
|
||||
Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items
|
||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
import { SchemeForm } from '../../_components/scheme-form'
|
||||
|
||||
export default function EditSchemePage() {
|
||||
const router = useRouter()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
schemesApi
|
||||
.get(id)
|
||||
.then(setScheme)
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||
router.push('/pricing/schemes')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, router])
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||
return <SchemeForm scheme={scheme} />
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
import { SchemeForm } from '../_components/scheme-form'
|
||||
|
||||
export default function SchemeDetailsPage() {
|
||||
const router = useRouter()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
schemesApi
|
||||
.get(id)
|
||||
.then(setScheme)
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||
router.push('/pricing/schemes')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, router])
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||
return <SchemeForm scheme={scheme} readOnly />
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useFieldArray, useWatch } from 'react-hook-form'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
schemes as schemesApi,
|
||||
regions as regionsApi,
|
||||
items as itemsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
|
||||
import { RULE_META, parseRuleConfig } from './scheme-labels'
|
||||
|
||||
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||
regionId: z.string().optional(),
|
||||
eligibleSegment: z.string().optional(),
|
||||
dateRangeFrom: z.string().min(1, 'Required'),
|
||||
dateRangeTo: z.string().min(1, 'Required'),
|
||||
isActive: z.boolean(),
|
||||
// Buy & get free
|
||||
triggerItemId: z.string(),
|
||||
triggerQty: z.number(),
|
||||
// Buy & get free + combo gift: what the store receives
|
||||
freeItemId: z.string(),
|
||||
freeQty: z.number(),
|
||||
// Combo gift: the products that must be bought together
|
||||
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
|
||||
// Flat discount
|
||||
discountPercent: z.number(),
|
||||
// Spend more, save more
|
||||
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
|
||||
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
|
||||
const pct = (n: number) => n > 0 && n <= 100
|
||||
|
||||
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
|
||||
fail(['dateRangeTo'], 'End date must not be before the start date')
|
||||
|
||||
if (v.ruleType === 'BuyXGetY') {
|
||||
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
|
||||
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
|
||||
}
|
||||
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
|
||||
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
|
||||
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
|
||||
}
|
||||
if (v.ruleType === 'FreeBundle') {
|
||||
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
|
||||
v.bundle.forEach((b, i) => {
|
||||
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
|
||||
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
|
||||
})
|
||||
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
|
||||
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
|
||||
}
|
||||
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
|
||||
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
|
||||
if (v.ruleType === 'SlabDiscount') {
|
||||
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
|
||||
v.tiers.forEach((t, i) => {
|
||||
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
|
||||
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const EMPTY: FormValues = {
|
||||
name: '',
|
||||
ruleType: 'BuyXGetY',
|
||||
regionId: '',
|
||||
eligibleSegment: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
triggerItemId: '',
|
||||
triggerQty: 10,
|
||||
freeItemId: '',
|
||||
freeQty: 1,
|
||||
bundle: [{ itemId: '', qty: 1 }],
|
||||
discountPercent: 10,
|
||||
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
|
||||
}
|
||||
|
||||
function toFormValues(scheme: SchemeResponse): FormValues {
|
||||
const cfg = parseRuleConfig(scheme)
|
||||
return {
|
||||
...EMPTY,
|
||||
name: scheme.name,
|
||||
ruleType: scheme.ruleType,
|
||||
regionId: scheme.regionId ?? '',
|
||||
eligibleSegment: scheme.eligibleSegment ?? '',
|
||||
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||
isActive: scheme.isActive,
|
||||
triggerItemId: scheme.triggerItemId ?? '',
|
||||
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
|
||||
freeItemId: scheme.freeItemId ?? '',
|
||||
freeQty: scheme.freeQty ?? EMPTY.freeQty,
|
||||
bundle: scheme.bundleComponents.length
|
||||
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
|
||||
: EMPTY.bundle,
|
||||
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
|
||||
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
|
||||
}
|
||||
}
|
||||
|
||||
interface SchemeFormProps {
|
||||
/** null = create a new scheme, otherwise edit this one. */
|
||||
scheme: SchemeResponse | null
|
||||
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
|
||||
const router = useRouter()
|
||||
const goBack = () => router.push('/pricing/schemes')
|
||||
const isEdit = !!scheme
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
|
||||
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
|
||||
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
|
||||
const v = useWatch({ control: form.control }) as FormValues
|
||||
const ruleType = v.ruleType ?? 'BuyXGetY'
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(scheme ? toFormValues(scheme) : EMPTY)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheme])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
|
||||
.then(([regions, items]) => {
|
||||
setRegionsList(getPaginatedItems(regions))
|
||||
setItemsList(getPaginatedItems(items))
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
|
||||
|
||||
// Live sentence so the user can check the offer reads the way they intended.
|
||||
const preview = useMemo(() => {
|
||||
const n = (x?: number) => (x && x > 0 ? x : '?')
|
||||
if (ruleType === 'BuyXGetY') {
|
||||
const buy = itemName(v.triggerItemId) ?? 'a product'
|
||||
const free = itemName(v.freeItemId) ?? 'a product'
|
||||
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
|
||||
}
|
||||
if (ruleType === 'FreeBundle') {
|
||||
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
|
||||
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
|
||||
}
|
||||
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
|
||||
const levels = [...(v.tiers ?? [])]
|
||||
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
|
||||
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [v, ruleType, itemsList])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
|
||||
const ruleConfig =
|
||||
values.ruleType === 'FlatPercent'
|
||||
? JSON.stringify({ discountPercent: values.discountPercent })
|
||||
: values.ruleType === 'SlabDiscount'
|
||||
? JSON.stringify({ tiers: values.tiers })
|
||||
: null
|
||||
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
|
||||
const n = (id: string) => itemName(id) ?? 'item'
|
||||
const autoName =
|
||||
values.ruleType === 'BuyXGetY'
|
||||
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||
: values.ruleType === 'FreeBundle'
|
||||
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||
: values.ruleType === 'FlatPercent'
|
||||
? `${values.discountPercent}% off the order`
|
||||
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
|
||||
const common = {
|
||||
name: values.name.trim() || autoName,
|
||||
ruleConfig,
|
||||
regionId: values.regionId || null,
|
||||
eligibleSegment: values.eligibleSegment?.trim() || null,
|
||||
dateRangeFrom: values.dateRangeFrom,
|
||||
dateRangeTo: values.dateRangeTo,
|
||||
isActive: values.isActive,
|
||||
}
|
||||
const goods = isGoods
|
||||
? {
|
||||
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
|
||||
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
|
||||
freeItemId: values.freeItemId,
|
||||
freeQty: values.freeQty,
|
||||
}
|
||||
: {}
|
||||
|
||||
try {
|
||||
if (!scheme) {
|
||||
await schemesApi.create({
|
||||
...common,
|
||||
ruleType: values.ruleType,
|
||||
...goods,
|
||||
bundleComponents:
|
||||
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
|
||||
})
|
||||
toast.add({ title: 'Scheme created', type: 'success' })
|
||||
} else {
|
||||
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
|
||||
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
|
||||
if (isGoods) {
|
||||
await schemesApi.setFreeGoodsRule(scheme.id, {
|
||||
triggerItemId: goods.triggerItemId,
|
||||
triggerQty: goods.triggerQty,
|
||||
freeItemId: values.freeItemId,
|
||||
freeQty: values.freeQty,
|
||||
})
|
||||
}
|
||||
if (values.ruleType === 'FreeBundle') {
|
||||
const keep = new Set(values.bundle.map((b) => b.itemId))
|
||||
for (const old of scheme.bundleComponents) {
|
||||
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
|
||||
}
|
||||
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
|
||||
}
|
||||
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||
}
|
||||
goBack()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
|
||||
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemsList.map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>
|
||||
{i.name} — {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={Number.isFinite(value) ? value : ''}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
aria-invalid={invalid}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{readOnly
|
||||
? 'How this offer is set up.'
|
||||
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
|
||||
</p>
|
||||
</div>
|
||||
{readOnly && scheme && (
|
||||
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
|
||||
Edit Scheme
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Scheme name"
|
||||
description="Optional. If left blank, the name is created from the offer."
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{RULE_ORDER.map((type) => {
|
||||
const selected = ruleType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
disabled={isEdit && !selected}
|
||||
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
|
||||
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
|
||||
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
|
||||
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can't be changed after it is created.</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Buy & get free ─────────────────────────────────────────── */}
|
||||
{ruleType === 'BuyXGetY' && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<p className="text-sm font-medium text-foreground">The store buys</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="triggerItemId"
|
||||
label="Product"
|
||||
render={({ field, invalid }) =>
|
||||
itemSelect(field.value as string, (id) => {
|
||||
field.onChange(id)
|
||||
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
|
||||
}, 'Select product…', invalid)
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="triggerQty"
|
||||
label="Quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">…and gets free</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeItemId"
|
||||
label="Free product"
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeQty"
|
||||
label="Free quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Combo gift ─────────────────────────────────────────────── */}
|
||||
{ruleType === 'FreeBundle' && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add product
|
||||
</Button>
|
||||
</div>
|
||||
{bundle.fields.map((row, index) => (
|
||||
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`bundle.${index}.itemId`}
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`bundle.${index}.qty`}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bundle.fields.length === 1}
|
||||
onClick={() => bundle.remove(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.formState.errors.bundle?.message && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">…and gets this free</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeItemId"
|
||||
label="Free product"
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeQty"
|
||||
label="Free quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Flat discount ──────────────────────────────────────────── */}
|
||||
{ruleType === 'FlatPercent' && (
|
||||
<div className="rounded-xl border p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discountPercent"
|
||||
label="Discount on the whole order (%)"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Spend more, save more ──────────────────────────────────── */}
|
||||
{ruleType === 'SlabDiscount' && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">Discount levels</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add level
|
||||
</Button>
|
||||
</div>
|
||||
{tiers.fields.map((row, index) => (
|
||||
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tiers.${index}.minOrderAmount`}
|
||||
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tiers.${index}.discountPercent`}
|
||||
label={index === 0 ? 'Discount (%)' : undefined}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={index === 0 ? 'mt-6' : ''}
|
||||
disabled={tiers.fields.length === 1}
|
||||
onClick={() => tiers.remove(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.formState.errors.tiers?.message && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
|
||||
<span className="font-medium text-foreground">Summary: </span>
|
||||
<span className="text-muted-foreground">{preview}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeFrom"
|
||||
label="Starts on"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeTo"
|
||||
label="Ends on"
|
||||
description="The offer still applies on this day."
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
description="Leave on All Regions to offer it everywhere."
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eligibleSegment"
|
||||
label="Store segment"
|
||||
description="Optional, e.g. Gold. Empty = every store."
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="All segments"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly && <div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={goBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||
</Button>
|
||||
</div>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||
|
||||
// Plain-language names for the four rule types, so users don't need to know the technical names.
|
||||
export const RULE_META: Record<SchemeRuleType, { label: string; description: string; example: string }> = {
|
||||
BuyXGetY: {
|
||||
label: 'Buy & get free',
|
||||
description: 'The store buys a quantity of a product and gets free units.',
|
||||
example: 'Buy 10 Soap, get 1 Soap free',
|
||||
},
|
||||
FreeBundle: {
|
||||
label: 'Combo gift',
|
||||
description: 'The store buys a set of products together and gets a free gift.',
|
||||
example: 'Buy Soap + Shampoo, get a Toothbrush free',
|
||||
},
|
||||
FlatPercent: {
|
||||
label: 'Flat discount',
|
||||
description: 'A fixed percentage off the whole order.',
|
||||
example: '10% off every order',
|
||||
},
|
||||
SlabDiscount: {
|
||||
label: 'Spend more, save more',
|
||||
description: 'The discount grows as the order value reaches higher levels.',
|
||||
example: '5% off above Rs. 5,000, 8% off above Rs. 10,000',
|
||||
},
|
||||
}
|
||||
|
||||
export interface SlabTierConfig {
|
||||
minOrderAmount: number
|
||||
discountPercent: number
|
||||
}
|
||||
|
||||
export function parseRuleConfig(scheme: Pick<SchemeResponse, 'ruleType' | 'ruleConfig'>) {
|
||||
try {
|
||||
const cfg = scheme.ruleConfig ? JSON.parse(scheme.ruleConfig) : null
|
||||
if (scheme.ruleType === 'FlatPercent') return { discountPercent: Number(cfg?.discountPercent) || 0, tiers: [] as SlabTierConfig[] }
|
||||
if (scheme.ruleType === 'SlabDiscount') return { discountPercent: 0, tiers: (cfg?.tiers ?? []) as SlabTierConfig[] }
|
||||
} catch {
|
||||
// Malformed config — fall through to empty.
|
||||
}
|
||||
return { discountPercent: 0, tiers: [] as SlabTierConfig[] }
|
||||
}
|
||||
|
||||
const money = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
|
||||
/** One-sentence description of what a scheme does, built from its saved rule. */
|
||||
export function describeScheme(scheme: SchemeResponse): string {
|
||||
switch (scheme.ruleType) {
|
||||
case 'BuyXGetY':
|
||||
return `Buy ${scheme.triggerQty ?? '?'} ${scheme.triggerItemName ?? 'item'}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||
case 'FreeBundle': {
|
||||
const parts = scheme.bundleComponents.map((c) => `${c.qty} ${c.itemName}`).join(' + ') || 'items'
|
||||
return `Buy ${parts}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||
}
|
||||
case 'FlatPercent':
|
||||
return `${parseRuleConfig(scheme).discountPercent}% off the order`
|
||||
case 'SlabDiscount': {
|
||||
const tiers = [...parseRuleConfig(scheme).tiers].sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||
return tiers.length ? tiers.map((t) => `${t.discountPercent}% off from ${money(t.minOrderAmount)}`).join(', ') : 'No levels set'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { SchemeResponse, RegionResponse } from '@/types/api'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||
regionId: z.string().optional(),
|
||||
dateRangeFrom: z.string().min(1, 'Required'),
|
||||
dateRangeTo: z.string().min(1, 'Required'),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
interface SchemeSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scheme: SchemeResponse | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
|
||||
const isEdit = !!scheme
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
ruleType: 'FlatPercent',
|
||||
regionId: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(
|
||||
scheme
|
||||
? {
|
||||
name: scheme.name,
|
||||
ruleType: scheme.ruleType,
|
||||
regionId: scheme.regionId ?? '',
|
||||
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||
isActive: scheme.isActive,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
ruleType: 'FlatPercent',
|
||||
regionId: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [open, scheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
regionsApi.list({ pageSize: 100 })
|
||||
.then((res) => setRegionsList(getPaginatedItems(res)))
|
||||
.catch(() => {
|
||||
toast.add({ title: 'Failed to load regions', type: 'error' })
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
const body = {
|
||||
name: values.name,
|
||||
ruleType: values.ruleType,
|
||||
regionId: values.regionId || null,
|
||||
dateRangeFrom: values.dateRangeFrom,
|
||||
dateRangeTo: values.dateRangeTo,
|
||||
isActive: values.isActive,
|
||||
}
|
||||
if (isEdit) {
|
||||
await schemesApi.update(scheme!.id, body)
|
||||
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||
} else {
|
||||
await schemesApi.create(body)
|
||||
toast.add({ title: 'Scheme created', type: 'success' })
|
||||
}
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Buy 2 Get 1 Free"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ruleType"
|
||||
label="Rule Type"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rule type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
|
||||
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
|
||||
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
|
||||
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeFrom"
|
||||
label="Date Range From"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeFrom"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeTo"
|
||||
label="Date Range To"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeTo"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
|
||||
interface SchemeViewSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scheme: SchemeResponse | null
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
|
||||
if (!scheme) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scheme Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Row label="Name" value={scheme.name} />
|
||||
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
|
||||
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
|
||||
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
|
||||
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
|
||||
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
|
||||
{scheme.baselineExpectedSales > 0 && (
|
||||
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
|
||||
)}
|
||||
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
|
||||
{scheme.triggerItemName && (
|
||||
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
|
||||
)}
|
||||
{scheme.freeItemName && (
|
||||
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
|
||||
)}
|
||||
<Row label="Created" value={fmtDate(scheme.createdAt)} />
|
||||
</div>
|
||||
|
||||
{scheme.bundleComponents.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scheme.bundleComponents.map((c) => (
|
||||
<tr key={c.itemId} className="border-t">
|
||||
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
|
||||
<td className="px-3 py-2 text-right">{c.qty}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
@@ -13,7 +12,8 @@ import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||
|
||||
import { RULE_META, describeScheme } from './scheme-labels'
|
||||
import { SchemeSheet } from './scheme-sheet'
|
||||
import { SchemeViewSheet } from './scheme-view-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -26,11 +26,14 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
|
||||
}
|
||||
|
||||
export function SchemesClient() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<SchemeResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SchemeResponse | null>(null)
|
||||
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
|
||||
const [viewOpen, setViewOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -49,15 +52,18 @@ export function SchemesClient() {
|
||||
}, [fetchData])
|
||||
|
||||
function openCreate() {
|
||||
router.push('/pricing/schemes/new')
|
||||
setEditing(null)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(scheme: SchemeResponse) {
|
||||
router.push(`/pricing/schemes/${scheme.id}/edit`)
|
||||
setEditing(scheme)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openView(scheme: SchemeResponse) {
|
||||
router.push(`/pricing/schemes/${scheme.id}`)
|
||||
setViewing(scheme)
|
||||
setViewOpen(true)
|
||||
}
|
||||
|
||||
async function handleDelete(scheme: SchemeResponse) {
|
||||
@@ -85,14 +91,11 @@ export function SchemesClient() {
|
||||
},
|
||||
{
|
||||
key: 'ruleType',
|
||||
title: 'Offer',
|
||||
title: 'Rule Type',
|
||||
fields: ['ruleType'],
|
||||
minWidth: 280,
|
||||
width: 160,
|
||||
cellRender: (row) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
|
||||
</div>
|
||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -174,6 +177,18 @@ export function SchemesClient() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<SchemeSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
scheme={editing}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
<SchemeViewSheet
|
||||
open={viewOpen}
|
||||
onClose={() => setViewOpen(false)}
|
||||
scheme={viewing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SchemeForm } from '../_components/scheme-form'
|
||||
|
||||
export default function NewSchemePage() {
|
||||
return <SchemeForm scheme={null} />
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||
import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
|
||||
import { FreeIssuePanel, type FreeLine } from '../../_components/free-issue-panel'
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
itemId: z.string().min(1, 'Item is required'),
|
||||
@@ -61,8 +60,6 @@ export default function EditShopPOPage() {
|
||||
const [confirmedByUserId, setConfirmedByUserId] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
|
||||
const [discountIds, setDiscountIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||
@@ -94,21 +91,9 @@ export default function EditShopPOPage() {
|
||||
setUsersList(users)
|
||||
form.reset({
|
||||
items: poRes.items
|
||||
.filter((i) => i.isActive && !i.isFree)
|
||||
.filter((i) => i.isActive)
|
||||
.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })),
|
||||
})
|
||||
setDiscountIds(poRes.discounts.map((d) => d.schemeId))
|
||||
setFreeLines(
|
||||
poRes.items
|
||||
.filter((i) => i.isActive && i.isFree && i.schemeId)
|
||||
.map((i) => ({
|
||||
itemId: i.itemId,
|
||||
itemName: i.itemName,
|
||||
quantity: i.quantity,
|
||||
schemeId: i.schemeId!,
|
||||
schemeName: i.schemeName ?? 'Scheme',
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' })
|
||||
@@ -153,13 +138,7 @@ export default function EditShopPOPage() {
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await shopPosApi.updateItems(id, {
|
||||
discountSchemeIds: discountIds,
|
||||
items: [
|
||||
...values.items,
|
||||
...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })),
|
||||
],
|
||||
})
|
||||
await shopPosApi.updateItems(id, { items: values.items })
|
||||
toast.add({ title: 'Shop PO updated', type: 'success' })
|
||||
router.push('/sales')
|
||||
} catch (err) {
|
||||
@@ -317,20 +296,6 @@ export default function EditShopPOPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FreeIssuePanel
|
||||
storeId={po.storeId}
|
||||
paidLines={watchedItems.map((l) => ({
|
||||
itemId: l.itemId,
|
||||
quantity: Number(l.quantity) || 0,
|
||||
unitPrice: Number(l.unitPrice) || 0,
|
||||
}))}
|
||||
freeLines={freeLines}
|
||||
onChange={setFreeLines}
|
||||
discountIds={discountIds}
|
||||
onDiscountChange={setDiscountIds}
|
||||
disabled={locked}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full rounded-2xl border p-6 sm:w-96">
|
||||
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function ShopPOViewPage() {
|
||||
|
||||
if (!po) return null
|
||||
|
||||
const activeItems = po.items.filter((i) => i.isActive).sort((a, b) => Number(a.isFree) - Number(b.isFree))
|
||||
const activeItems = po.items.filter((i) => i.isActive)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -155,7 +155,6 @@ export default function ShopPOViewPage() {
|
||||
<tr>
|
||||
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="w-28 px-3 py-2 text-right font-medium">Quantity</th>
|
||||
<th className="w-28 px-3 py-2 text-right font-medium">Free Issue</th>
|
||||
<th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Unit Price</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
|
||||
@@ -164,21 +163,15 @@ export default function ShopPOViewPage() {
|
||||
<tbody>
|
||||
{activeItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
|
||||
No items on this PO.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
activeItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
{item.itemName}
|
||||
{item.isFree && item.schemeName && (
|
||||
<Badge variant="outline" className="ml-2">{item.schemeName}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{item.isFree ? '-' : item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right">{item.isFree ? item.quantity : '-'}</td>
|
||||
<td className="px-3 py-2">{item.itemName}</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantityFulfilled}</td>
|
||||
<td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
|
||||
@@ -188,14 +181,8 @@ export default function ShopPOViewPage() {
|
||||
</tbody>
|
||||
{activeItems.length > 0 && (
|
||||
<tfoot>
|
||||
{po.discounts.map((d) => (
|
||||
<tr key={d.schemeId} className="border-t bg-muted/30">
|
||||
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount ({d.schemeName})</td>
|
||||
<td className="px-3 py-2 text-right text-sm font-medium text-foreground">-{fmtAmount(d.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t bg-muted/30">
|
||||
<td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">
|
||||
<td colSpan={4} className="px-3 py-3 text-right text-sm font-medium text-foreground">
|
||||
Total
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi } from '@/lib/api/index'
|
||||
import type { SchemeSuggestion } from '@/types/api'
|
||||
|
||||
export interface FreeLine {
|
||||
itemId: string
|
||||
itemName: string
|
||||
quantity: number
|
||||
schemeId: string
|
||||
schemeName: string
|
||||
}
|
||||
|
||||
interface PaidLine {
|
||||
itemId: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
storeId: string
|
||||
paidLines: PaidLine[]
|
||||
freeLines: FreeLine[]
|
||||
onChange: (lines: FreeLine[]) => void
|
||||
/** Accepted percentage-discount scheme ids (at most one). */
|
||||
discountIds: string[]
|
||||
onDiscountChange: (ids: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const isFreeGoods = (s: SchemeSuggestion) => s.ruleType === 'BuyXGetY' || s.ruleType === 'FreeBundle'
|
||||
const isDiscount = (s: SchemeSuggestion) => s.ruleType === 'FlatPercent' || s.ruleType === 'SlabDiscount'
|
||||
const money = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
|
||||
/**
|
||||
* Suggests free-issue schemes for the paid lines being entered. Nothing is added automatically: the user
|
||||
* accepts a suggestion, after which the free line stays editable. If a paid-quantity change shrinks the
|
||||
* entitlement, accepted free quantities are trimmed to match (and the user is told).
|
||||
*/
|
||||
export function FreeIssuePanel({ storeId, paidLines, freeLines, onChange, discountIds, onDiscountChange, disabled }: Props) {
|
||||
const [suggestions, setSuggestions] = useState<SchemeSuggestion[]>([])
|
||||
const freeRef = useRef(freeLines)
|
||||
freeRef.current = freeLines
|
||||
const discountRef = useRef(discountIds)
|
||||
discountRef.current = discountIds
|
||||
|
||||
const paidKey = JSON.stringify(
|
||||
paidLines.filter((l) => l.itemId && l.quantity > 0).map((l) => [l.itemId, l.quantity, l.unitPrice])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeId) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const lines = paidLines.filter((l) => l.itemId && l.quantity > 0)
|
||||
if (lines.length === 0) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
schemesApi
|
||||
.suggest({
|
||||
storeId,
|
||||
items: lines.map((l) => ({
|
||||
itemId: l.itemId,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice > 0 ? l.unitPrice : null,
|
||||
})),
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setSuggestions(result)
|
||||
reconcile(result)
|
||||
})
|
||||
.catch(() => {
|
||||
// Suggestions are advisory; the PO can still be saved without them.
|
||||
if (!cancelled) setSuggestions([])
|
||||
})
|
||||
}, 400)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storeId, paidKey])
|
||||
|
||||
// Trim or drop accepted free lines that the current paid lines no longer entitle.
|
||||
function reconcile(result: SchemeSuggestion[]) {
|
||||
const current = freeRef.current
|
||||
let changed = false
|
||||
const next: FreeLine[] = []
|
||||
for (const line of current) {
|
||||
const s = result.find((r) => r.schemeId === line.schemeId)
|
||||
const entitled = s?.eligible ? Math.floor(s.freeQty ?? 0) : 0
|
||||
if (entitled <= 0) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const used = next.filter((n) => n.schemeId === line.schemeId).reduce((sum, n) => sum + n.quantity, 0)
|
||||
const allowed = Math.max(0, entitled - used)
|
||||
if (allowed <= 0) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (line.quantity > allowed) {
|
||||
changed = true
|
||||
next.push({ ...line, quantity: allowed })
|
||||
} else {
|
||||
next.push(line)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
onChange(next)
|
||||
toast.add({
|
||||
title: 'Free issue adjusted',
|
||||
description: 'Free quantities were reduced to match what the current order qualifies for.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
// An accepted discount the order no longer qualifies for is dropped.
|
||||
const stillValid = discountRef.current.filter((id) => result.some((r) => r.schemeId === id && r.eligible))
|
||||
if (stillValid.length !== discountRef.current.length) {
|
||||
onDiscountChange(stillValid)
|
||||
toast.add({ title: 'Discount removed', description: 'The order no longer qualifies for the discount.', type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
function accept(s: SchemeSuggestion, remaining: number) {
|
||||
if (!s.freeItemId) return
|
||||
const existing = freeLines.find((l) => l.schemeId === s.schemeId)
|
||||
if (existing) {
|
||||
onChange(freeLines.map((l) => (l === existing ? { ...l, quantity: l.quantity + remaining } : l)))
|
||||
return
|
||||
}
|
||||
onChange([
|
||||
...freeLines,
|
||||
{
|
||||
itemId: s.freeItemId,
|
||||
itemName: s.freeItemName ?? 'Free item',
|
||||
quantity: remaining,
|
||||
schemeId: s.schemeId,
|
||||
schemeName: s.schemeName,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function setQuantity(index: number, quantity: number) {
|
||||
onChange(freeLines.map((l, i) => (i === index ? { ...l, quantity } : l)))
|
||||
}
|
||||
|
||||
const goods = suggestions.filter(isFreeGoods)
|
||||
const offers = goods
|
||||
.filter((s) => s.eligible && s.freeItemId)
|
||||
.map((s) => {
|
||||
const accepted = freeLines.filter((l) => l.schemeId === s.schemeId).reduce((sum, l) => sum + l.quantity, 0)
|
||||
return { s, remaining: Math.floor(s.freeQty ?? 0) - accepted }
|
||||
})
|
||||
.filter((o) => o.remaining > 0)
|
||||
const nudges = goods.filter((s) => !s.eligible && s.reason)
|
||||
const discountOffers = suggestions.filter((s) => isDiscount(s) && s.eligible && s.discountAmount != null)
|
||||
const applied = discountOffers.filter((s) => discountIds.includes(s.schemeId))
|
||||
const available = discountOffers.filter((s) => !discountIds.includes(s.schemeId))
|
||||
const orderTotal = paidLines.reduce((sum, l) => sum + (l.quantity || 0) * (l.unitPrice || 0), 0)
|
||||
|
||||
if (offers.length === 0 && nudges.length === 0 && freeLines.length === 0 && discountOffers.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<h3 className="text-sm font-medium text-foreground">Offers & Free Issues</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Suggested from active schemes. Add them to the order, then adjust the quantity if needed.
|
||||
</p>
|
||||
|
||||
{(applied.length > 0 || available.length > 0) && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{applied.map((s) => (
|
||||
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Discount applied: <span className="font-medium text-foreground">-{money(s.discountAmount!)}</span>
|
||||
{' '}({money(orderTotal)} → {money(Math.max(0, orderTotal - s.discountAmount!))} on the invoice)
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onDiscountChange([])}>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{available.map((s) => (
|
||||
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Saves <span className="font-medium text-foreground">{money(s.discountAmount!)}</span> on this order
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" disabled={disabled || discountIds.length > 0} onClick={() => onDiscountChange([s.schemeId])}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offers.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{offers.map(({ s, remaining }) => (
|
||||
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Qualifies for <span className="font-medium text-foreground">{remaining} free {s.freeItemName ?? 'item'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" disabled={disabled} onClick={() => accept(s, remaining)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add free
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nudges.length > 0 && (
|
||||
<ul className="mt-4 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{nudges.map((s) => (
|
||||
<li key={s.schemeId}>
|
||||
<span className="font-medium text-foreground">{s.schemeName}:</span> {s.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{freeLines.length > 0 && (
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Free item</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Scheme</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">Free qty</th>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{freeLines.map((line, index) => (
|
||||
<tr key={`${line.schemeId}-${index}`} className="border-t">
|
||||
<td className="px-3 py-2">{line.itemName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline">{line.schemeName}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
value={line.quantity}
|
||||
onChange={(e) => setQuantity(index, Math.max(1, Math.floor(Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(freeLines.filter((_, i) => i !== index))}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,12 +20,11 @@ import {
|
||||
invoices as invoicesApi,
|
||||
users as usersApi,
|
||||
items as itemsApi,
|
||||
schemes as schemesApi,
|
||||
priceLists as priceListsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, SchemeSuggestion, UserResponse } from '@/types/api'
|
||||
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api'
|
||||
|
||||
interface AddedLine {
|
||||
key: string
|
||||
@@ -60,11 +59,6 @@ export default function InvoiceEditPage() {
|
||||
const [userId, setUserId] = useState('')
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Scheme discount for the lines as currently typed, recalculated by the server (null until the first answer).
|
||||
const [previewDiscount, setPreviewDiscount] = useState<number | null>(null)
|
||||
// The percentage-discount scheme applied to this invoice's order (at most one), and the ones it currently qualifies for.
|
||||
const [discountIds, setDiscountIds] = useState<string[]>([])
|
||||
const [discountOffers, setDiscountOffers] = useState<SchemeSuggestion[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
invoicesApi.get(invoiceId)
|
||||
@@ -75,7 +69,6 @@ export default function InvoiceEditPage() {
|
||||
return
|
||||
}
|
||||
setFull(inv)
|
||||
setDiscountIds(inv.discounts.map((d) => d.schemeId))
|
||||
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
|
||||
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
|
||||
@@ -111,65 +104,11 @@ export default function InvoiceEditPage() {
|
||||
quantity: Number(a.qty) || 0,
|
||||
unitPrice: Number(a.price) || 0,
|
||||
isRemoved: false,
|
||||
isFree: false,
|
||||
schemeId: null,
|
||||
schemeName: null,
|
||||
}))
|
||||
const items = [...existing, ...extra]
|
||||
const subtotal = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
const discountAmount = Math.min(previewDiscount ?? full.discountAmount, subtotal)
|
||||
return { ...full, items, discountAmount, totalAmount: subtotal - discountAmount }
|
||||
}, [full, qty, price, removed, added, previewDiscount])
|
||||
|
||||
// Adding, removing or re-pricing lines can change a percentage/slab discount, so ask the server to recalculate it.
|
||||
const previewKey = JSON.stringify(
|
||||
live?.items.map((i) => [i.itemId, i.quantity, i.unitPrice, i.isFree, i.isRemoved]) ?? [],
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!full || !live) return
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
invoicesApi
|
||||
.previewTotals(full.id, live.items.map((i) => ({
|
||||
itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice, isFree: i.isFree, isRemoved: i.isRemoved, schemeId: i.schemeId,
|
||||
})), discountIds)
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
setPreviewDiscount(r.discountAmount)
|
||||
// Lowering the paid item that earned a free issue lowers the free quantity with it.
|
||||
const left = new Map(r.freeEntitlements.map((e) => [e.schemeId, e.quantity]))
|
||||
let reduced = false
|
||||
for (const it of live.items) {
|
||||
if (!it.isFree || !it.schemeId || it.isRemoved) continue
|
||||
const allowed = left.get(it.schemeId) ?? 0
|
||||
if (it.quantity <= allowed) { left.set(it.schemeId, allowed - it.quantity); continue }
|
||||
reduced = true
|
||||
left.set(it.schemeId, 0)
|
||||
if (allowed === 0) setRemoved((x) => ({ ...x, [it.id]: true }))
|
||||
else setQty((x) => ({ ...x, [it.id]: String(allowed) }))
|
||||
}
|
||||
if (reduced) toast.add({ title: 'Free issue reduced', description: 'The free quantity was lowered to match the paid quantity.', type: 'warning' })
|
||||
})
|
||||
.catch(() => { /* keep the last known discount; the save recalculates it anyway */ })
|
||||
}, 400)
|
||||
return () => { cancelled = true; clearTimeout(timer) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [full?.id, previewKey, discountIds])
|
||||
|
||||
// Which percentage discounts the lines as typed qualify for (so one can be applied or removed here).
|
||||
useEffect(() => {
|
||||
if (!full || !live) return
|
||||
const paid = live.items.filter((i) => !i.isFree && !i.isRemoved && i.quantity > 0)
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
schemesApi
|
||||
.suggest({ storeId: full.storeId, items: paid.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice > 0 ? i.unitPrice : null })) })
|
||||
.then((r) => { if (!cancelled) setDiscountOffers(r.filter((x) => (x.ruleType === 'FlatPercent' || x.ruleType === 'SlabDiscount') && x.eligible)) })
|
||||
.catch(() => { if (!cancelled) setDiscountOffers([]) })
|
||||
}, 400)
|
||||
return () => { cancelled = true; clearTimeout(timer) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [full?.id, previewKey])
|
||||
const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
return { ...full, items, totalAmount }
|
||||
}, [full, qty, price, removed, added])
|
||||
|
||||
// Items not already on the invoice (a deleted line can be restored with Undo instead).
|
||||
const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id))
|
||||
@@ -257,7 +196,6 @@ export default function InvoiceEditPage() {
|
||||
...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
|
||||
],
|
||||
userId,
|
||||
discountIds,
|
||||
)
|
||||
toast.add({ title: 'Invoice updated', type: 'success' })
|
||||
// Navigating away right after a successful save; clear the unsaved-changes guard first.
|
||||
@@ -298,36 +236,6 @@ export default function InvoiceEditPage() {
|
||||
<p className="text-sm text-muted-foreground">Loading invoice…</p>
|
||||
) : (
|
||||
<>
|
||||
{(discountOffers.length > 0 || discountIds.length > 0) && (
|
||||
<div className="mx-auto flex w-full max-w-[210mm] flex-col gap-2 rounded-xl border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Scheme discount</p>
|
||||
{discountOffers.map((o) => {
|
||||
const applied = discountIds.includes(o.schemeId)
|
||||
return (
|
||||
<div key={o.schemeId} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>
|
||||
<span className="font-medium">{o.schemeName}</span>{' '}
|
||||
<span className="text-muted-foreground">saves Rs. {(o.discountAmount ?? 0).toLocaleString()} on these lines</span>
|
||||
</span>
|
||||
{applied ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={discountIds.length > 0} onClick={() => setDiscountIds([o.schemeId])}>
|
||||
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{discountIds.length > 0 && !discountOffers.some((o) => discountIds.includes(o.schemeId)) && (
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>An applied discount no longer qualifies for these lines (it counts as Rs. 0).</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoiceTemplate
|
||||
invoice={live}
|
||||
renderQuantity={(item) => (
|
||||
@@ -340,9 +248,7 @@ export default function InvoiceEditPage() {
|
||||
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
renderUnitPrice={(item) => item.isFree ? (
|
||||
<span className="text-sm text-muted-foreground">Free</span>
|
||||
) : (
|
||||
renderUnitPrice={(item) => (
|
||||
<Input
|
||||
type="number" min={0} step="0.01"
|
||||
className="ml-auto h-8 w-28 text-right"
|
||||
@@ -424,7 +330,7 @@ export default function InvoiceEditPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button>
|
||||
<Button type="button" onClick={save} disabled={saving || !userId}>
|
||||
<Button type="button" onClick={save} disabled={saving || (!dirty && !pendingLine())}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +199,6 @@ export default function InvoiceViewPage() {
|
||||
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Billed</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Free Issue</th>
|
||||
<th className="w-28 px-3 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
|
||||
</tr>
|
||||
@@ -207,23 +206,19 @@ export default function InvoiceViewPage() {
|
||||
<tbody>
|
||||
{invoice.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
|
||||
</tr>
|
||||
) : (
|
||||
[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
|
||||
invoice.items.map((i) => (
|
||||
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
|
||||
<td className="px-3 py-2">
|
||||
{i.itemName}
|
||||
{i.isFree && <Badge variant="outline" className="ml-2 no-underline">{i.schemeName ?? "Free issue"}</Badge>}
|
||||
{i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
|
||||
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{i.isFree ? '-' : (i.orderedQuantity ?? '-')}</td>
|
||||
<td className={`px-3 py-2 text-right ${!i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||
{i.isFree ? '-' : i.quantity}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right ${i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||
{i.isFree ? i.quantity : '-'}
|
||||
<td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td>
|
||||
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||
{i.quantity}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td>
|
||||
@@ -231,26 +226,6 @@ export default function InvoiceViewPage() {
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
{invoice.items.length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="border-t bg-muted/30">
|
||||
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Subtotal</td>
|
||||
<td className="px-3 py-2 text-right text-sm font-medium">
|
||||
{fmtAmount(invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0))}
|
||||
</td>
|
||||
</tr>
|
||||
{invoice.discountAmount > 0 && (
|
||||
<tr className="bg-muted/30">
|
||||
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount</td>
|
||||
<td className="px-3 py-2 text-right text-sm font-medium">-{fmtAmount(invoice.discountAmount)}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t bg-muted/30">
|
||||
<td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">Total</td>
|
||||
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">{fmtAmount(invoice.totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api'
|
||||
import { FreeIssuePanel, type FreeLine } from '../_components/free-issue-panel'
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
itemId: z.string().min(1, 'Item is required'),
|
||||
@@ -58,8 +57,6 @@ export default function NewShopPOPage() {
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
|
||||
const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({})
|
||||
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
|
||||
const [discountIds, setDiscountIds] = useState<string[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -128,13 +125,9 @@ export default function NewShopPOPage() {
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await shopPosApi.create({
|
||||
discountSchemeIds: discountIds,
|
||||
storeId: values.storeId,
|
||||
raisedByUserId: values.raisedByUserId,
|
||||
items: [
|
||||
...values.items,
|
||||
...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })),
|
||||
],
|
||||
items: values.items,
|
||||
})
|
||||
toast.add({ title: 'Shop PO created', type: 'success' })
|
||||
router.push('/sales')
|
||||
@@ -350,19 +343,6 @@ export default function NewShopPOPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FreeIssuePanel
|
||||
storeId={watchedStoreId}
|
||||
paidLines={watchedItems.map((l) => ({
|
||||
itemId: l.itemId,
|
||||
quantity: Number(l.quantity) || 0,
|
||||
unitPrice: Number(l.unitPrice) || 0,
|
||||
}))}
|
||||
freeLines={freeLines}
|
||||
onChange={setFreeLines}
|
||||
discountIds={discountIds}
|
||||
onDiscountChange={setDiscountIds}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full rounded-2xl border p-6 sm:w-96">
|
||||
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import {
|
||||
@@ -166,14 +164,8 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right no-underline">
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon-sm"
|
||||
className={removed[i.id] ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
|
||||
aria-label={removed[i.id] ? 'Restore line' : 'Delete line'}
|
||||
title={removed[i.id] ? 'Restore line' : 'Delete line'}
|
||||
onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}
|
||||
>
|
||||
<HugeiconsIcon icon={removed[i.id] ? Undo02Icon : Delete02Icon} size={16} />
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}>
|
||||
{removed[i.id] ? 'Undo' : 'Remove'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -34,11 +34,9 @@ const statusLabel = (invoice: InvoiceResponse) =>
|
||||
|
||||
// Removed lines stay on the document (struck through) but are excluded from every total.
|
||||
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
|
||||
const subtotal = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
const discount = Math.min(invoice.discountAmount ?? 0, subtotal)
|
||||
const total = subtotal - discount
|
||||
const total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
const paid = payments.reduce((s, p) => s + p.amount, 0)
|
||||
return { subtotal, discount, total, paid, balance: total - paid }
|
||||
return { total, paid, balance: total - paid }
|
||||
}
|
||||
|
||||
export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
|
||||
@@ -58,12 +56,9 @@ function A4Sheet({
|
||||
showAgentEdits,
|
||||
className,
|
||||
}: Omit<InvoiceTemplateProps, 'paper'>) {
|
||||
const { subtotal, discount, total, paid, balance } = totals(invoice, payments)
|
||||
const { total, paid, balance } = totals(invoice, payments)
|
||||
const status = statusLabel(invoice)
|
||||
const hasRemoved = invoice.items.some((i) => i.isRemoved)
|
||||
const hasFree = invoice.items.some((i) => i.isFree)
|
||||
// Free issues are listed after every paid line.
|
||||
const rows = [...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,7 +107,6 @@ function A4Sheet({
|
||||
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="px-2 py-2 text-left font-medium">Item</th>
|
||||
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
|
||||
{hasFree && <th className="w-24 px-2 py-2 text-right font-medium">Free Issue</th>}
|
||||
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
|
||||
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
|
||||
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
|
||||
@@ -124,25 +118,15 @@ function A4Sheet({
|
||||
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((item) => (
|
||||
invoice.items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
|
||||
>
|
||||
<td className="px-2 py-2.5">
|
||||
{item.itemName}
|
||||
{item.isFree && item.schemeName && (
|
||||
<span className="block text-xs text-neutral-500 no-underline">Free issue · {item.schemeName}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5">{item.itemName}</td>
|
||||
<td className="px-2 py-2.5 text-right">
|
||||
{item.isFree ? '-' : renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
|
||||
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
|
||||
</td>
|
||||
{hasFree && (
|
||||
<td className="px-2 py-2.5 text-right">
|
||||
{item.isFree ? (renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)) : '-'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-2.5 text-right">
|
||||
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
|
||||
</td>
|
||||
@@ -176,14 +160,8 @@ function A4Sheet({
|
||||
<dl className="w-72 break-inside-avoid text-sm">
|
||||
<div className="flex justify-between border-b py-1.5">
|
||||
<dt className="text-neutral-500">Subtotal</dt>
|
||||
<dd>{fmtMoney(subtotal)}</dd>
|
||||
<dd>{fmtMoney(total)}</dd>
|
||||
</div>
|
||||
{discount > 0 && (
|
||||
<div className="flex justify-between border-b py-1.5">
|
||||
<dt className="text-neutral-500">Scheme discount</dt>
|
||||
<dd>-{fmtMoney(discount)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
|
||||
<dt>Total</dt>
|
||||
<dd>{fmtMoney(total)}</dd>
|
||||
@@ -247,7 +225,7 @@ function ReceiptSheet({
|
||||
showAgentEdits,
|
||||
className,
|
||||
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
|
||||
const { subtotal, discount, total, paid, balance } = totals(invoice, payments)
|
||||
const { total, paid, balance } = totals(invoice, payments)
|
||||
const status = statusLabel(invoice)
|
||||
const narrow = paper === '58mm'
|
||||
const rule = <div className="my-2 border-t border-dashed border-black" />
|
||||
@@ -274,21 +252,12 @@ function ReceiptSheet({
|
||||
</div>
|
||||
{rule}
|
||||
<div className="space-y-1.5">
|
||||
{[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
|
||||
{invoice.items.map((i) => (
|
||||
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
|
||||
<p>{i.itemName}</p>
|
||||
<p className="flex justify-between">
|
||||
{i.isFree ? (
|
||||
<>
|
||||
<span>{fmtQty(i.quantity)} FREE{i.schemeName ? ` (${i.schemeName})` : ''}</span>
|
||||
<span>{num(0)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
|
||||
<span>{num(i.quantity * i.unitPrice)}</span>
|
||||
</>
|
||||
)}
|
||||
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
|
||||
<span>{num(i.quantity * i.unitPrice)}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -296,14 +265,8 @@ function ReceiptSheet({
|
||||
{rule}
|
||||
<p className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{fmtMoney(subtotal)}</span>
|
||||
<span>{fmtMoney(total)}</span>
|
||||
</p>
|
||||
{discount > 0 && (
|
||||
<p className="flex justify-between">
|
||||
<span>Scheme discount</span>
|
||||
<span>-{fmtMoney(discount)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
|
||||
<p className="flex justify-between">
|
||||
<span>TOTAL</span>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
PriceResolution,
|
||||
SchemeResponse,
|
||||
SchemeRuleType,
|
||||
SchemeSuggestion,
|
||||
} from '@/types/api'
|
||||
|
||||
// ─── Price Lists ──────────────────────────────────────────────────────────────
|
||||
@@ -90,17 +89,4 @@ export const schemes = {
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/schemes/${id}`),
|
||||
|
||||
setFreeGoodsRule: (id: string, body: { triggerItemId?: string | null; triggerQty?: number | null; freeItemId: string; freeQty: number }) =>
|
||||
api.put<SchemeResponse>(`/schemes/${id}/free-goods-rule`, body).then(r => r.data),
|
||||
|
||||
upsertBundleComponent: (id: string, itemId: string, qty: number) =>
|
||||
api.put<SchemeResponse>(`/schemes/${id}/bundle-components/${itemId}`, { qty }).then(r => r.data),
|
||||
|
||||
removeBundleComponent: (id: string, itemId: string) =>
|
||||
api.delete<SchemeResponse>(`/schemes/${id}/bundle-components/${itemId}`).then(r => r.data),
|
||||
|
||||
/** Schemes the lines of a not-yet-saved Shop PO qualify for (or are close to). */
|
||||
suggest: (body: { storeId: string; items: { itemId: string; quantity: number; unitPrice?: number | null }[] }) =>
|
||||
api.post<SchemeSuggestion[]>('/schemes/suggest', body).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -21,22 +21,16 @@ export interface ShopPOItemBody {
|
||||
itemId: string
|
||||
quantity: number
|
||||
unitPrice?: number | null
|
||||
/** Free issue accepted from a scheme suggestion; priced 0 by the server. */
|
||||
isFree?: boolean
|
||||
schemeId?: string | null
|
||||
}
|
||||
|
||||
export interface CreateShopPOBody {
|
||||
storeId: string
|
||||
items: ShopPOItemBody[]
|
||||
raisedByUserId?: string | null
|
||||
/** The one flat/slab discount scheme the user accepted. */
|
||||
discountSchemeIds?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateShopPOItemsBody {
|
||||
items: ShopPOItemBody[]
|
||||
discountSchemeIds?: string[]
|
||||
}
|
||||
|
||||
export interface ShopPOListParams extends PageParams {
|
||||
@@ -103,12 +97,8 @@ export const invoices = {
|
||||
finalize: (id: string, finalizedByUserId?: string | null, adjustments?: { invoiceItemId: string; adjustedQuantity: number; isRemoved?: boolean }[]) =>
|
||||
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId, adjustments }).then(r => r.data),
|
||||
|
||||
updateItems: (id: string, items: { invoiceItemId?: string; itemId?: string; quantity: number; unitPrice?: number | null; isRemoved?: boolean }[], editedByUserId?: string | null, discountSchemeIds?: string[]) =>
|
||||
api.put<InvoiceResponse>(`/invoices/${id}/items`, { items, editedByUserId, discountSchemeIds }).then(r => r.data),
|
||||
|
||||
/** What the discount and total would be for these unsaved lines (draft editor preview). */
|
||||
previewTotals: (id: string, items: { itemId: string; quantity: number; unitPrice: number; isFree?: boolean; isRemoved?: boolean; schemeId?: string | null }[], discountSchemeIds?: string[]) =>
|
||||
api.post<{ discountAmount: number; totalAmount: number; freeEntitlements: { schemeId: string; quantity: number }[] }>(`/invoices/${id}/preview-totals`, { items, discountSchemeIds }).then(r => r.data),
|
||||
updateItems: (id: string, items: { invoiceItemId?: string; itemId?: string; quantity: number; unitPrice?: number | null; isRemoved?: boolean }[], editedByUserId?: string | null) =>
|
||||
api.put<InvoiceResponse>(`/invoices/${id}/items`, { items, editedByUserId }).then(r => r.data),
|
||||
|
||||
versions: (id: string) =>
|
||||
api.get<InvoiceVersionResponse[]>(`/invoices/${id}/versions`).then(r => r.data),
|
||||
|
||||
@@ -661,18 +661,6 @@ export interface SchemeResponse {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SchemeSuggestion {
|
||||
schemeId: string
|
||||
schemeName: string
|
||||
ruleType: SchemeRuleType
|
||||
eligible: boolean
|
||||
reason: string | null
|
||||
discountAmount: number | null
|
||||
freeItemId: string | null
|
||||
freeQty: number | null
|
||||
freeItemName: string | null
|
||||
}
|
||||
|
||||
// ─── Suppliers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SupplierResponse {
|
||||
@@ -985,15 +973,6 @@ export interface ShopPOItemResponse {
|
||||
quantityFulfilled: number
|
||||
unitPrice: number
|
||||
isActive: boolean
|
||||
isFree: boolean
|
||||
schemeId: string | null
|
||||
schemeName: string | null
|
||||
}
|
||||
|
||||
export interface ShopPODiscount {
|
||||
schemeId: string
|
||||
schemeName: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface ShopPOResponse {
|
||||
@@ -1021,7 +1000,6 @@ export interface ShopPOResponse {
|
||||
cancelReason: string | null
|
||||
cancelledByName: string | null
|
||||
cancelledByRole: string | null
|
||||
discounts: ShopPODiscount[]
|
||||
}
|
||||
|
||||
export interface InvoiceItemResponse {
|
||||
@@ -1033,9 +1011,6 @@ export interface InvoiceItemResponse {
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
isRemoved: boolean
|
||||
isFree: boolean
|
||||
schemeId: string | null
|
||||
schemeName: string | null
|
||||
}
|
||||
|
||||
export interface InvoiceVersionItemResponse {
|
||||
@@ -1075,7 +1050,6 @@ export interface InvoiceResponse {
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
items: InvoiceItemResponse[]
|
||||
discounts: ShopPODiscount[]
|
||||
}
|
||||
|
||||
export interface OutstandingPaymentsResponse {
|
||||
|
||||
Reference in New Issue
Block a user