Compare commits

..

15 Commits

Author SHA1 Message Date
DeepnaPooja b7f58e6b73 Merge pull request 'Fix dashoard issues' (#85) from dev into production
Reviewed-on: #85
2026-10-22 10:55:41 +05:30
DeepnaPooja e13ec0f109 Merge pull request 'Fix dashboard stat card handling issue' (#84) from feat-notification into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m59s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m29s
Reviewed-on: #84
2026-10-22 10:54:52 +05:30
DeepnaPooja 13fdb28ec7 Merge pull request 'Dev' (#83) from dev into production
Reviewed-on: #83
2026-10-21 13:14:19 +05:30
DeepnaPooja 39ad424d21 Merge pull request 'Invoice flow' (#82) from feat-notification into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m58s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m20s
Reviewed-on: #82
2026-10-21 13:13:21 +05:30
DeepnaPooja ee9a5385bf Merge pull request 'Dev' (#81) from dev into production
Reviewed-on: #81
2026-10-20 17:31:19 +05:30
kalana_Thilakarathna 138bf2f925 Merge pull request 'Add invoice creation mode support to Shop PO flow' (#79) from dev into production
Reviewed-on: #79
2026-10-20 14:02:31 +05:30
kalana_Thilakarathna 1b4461dc12 Merge pull request 'Add manual invoice creation mode with new API & UI flow' (#78) from dev into production
Reviewed-on: #78
2026-10-20 13:39:12 +05:30
kalana_Thilakarathna cc33ecdd54 Merge pull request 'Implement store credit tracking and enforcement' (#77) from dev into production
Reviewed-on: #77
2026-10-20 11:51:10 +05:30
kalana_Thilakarathna f77db46a02 Merge pull request 'Dev' (#76) from dev into production
Reviewed-on: #76
2026-10-19 23:38:07 +05:30
kalana_Thilakarathna 267144f7a1 Merge pull request 'feat: add EmptyState component for improved no-data display in supplier payments' (#75) from dev into production
Reviewed-on: #75
2026-10-19 22:35:48 +05:30
kalana_Thilakarathna efb5657b6b Merge pull request 'feat: update row actions to include payment functionality and add corresponding icon' (#74) from dev into production
Reviewed-on: #74
2026-10-19 22:13:07 +05:30
kalana_Thilakarathna 622b75e187 Merge pull request 'feat: integrate payment functionality with new payment sheet and enhance supplier payments UI' (#73) from dev into production
Reviewed-on: #73
2026-10-19 22:02:45 +05:30
kalana_Thilakarathna 293df41d4e Merge pull request 'Dev' (#72) from dev into production
Reviewed-on: #72
2026-10-19 21:41:42 +05:30
DeepnaPooja 5e81fd77b1 Merge pull request 'Dev' (#71) from dev into production
Reviewed-on: #71
2026-10-19 16:47:06 +05:30
kalana_Thilakarathna a1fd5f1a08 Merge pull request 'feat: add cheque books and received cheques management' (#69) from dev into production
Reviewed-on: #69
2026-10-19 16:12:32 +05:30
39 changed files with 575 additions and 8330 deletions
@@ -52,13 +52,6 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } 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")] [HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id) public async Task<IActionResult> GetVersions(Guid id)
{ {
+4 -16
View File
@@ -1,5 +1,4 @@
using Distribution.Features.ShopPO; using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Invoice; 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. // 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); 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);
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 InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved); 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? OrderedQuantity,
int Quantity, int Quantity,
decimal UnitPrice, decimal UnitPrice,
bool IsRemoved, bool IsRemoved);
bool IsFree = false,
Guid? SchemeId = null,
string? SchemeName = null);
public record InvoiceResponse( public record InvoiceResponse(
Guid Id, Guid Id,
@@ -60,7 +49,6 @@ public record InvoiceResponse(
decimal PaidAmount, decimal PaidAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt,
List<InvoiceItemResponse> Items, List<InvoiceItemResponse> Items);
List<ShopPODiscountResponse>? Discounts = null);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount); public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
+11 -58
View File
@@ -1,5 +1,4 @@
using Distribution.Features.Scheme; using Distribution.Features.ShopPO;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,7 +9,6 @@ namespace Distribution.Features.Invoice;
public class InvoiceService( public class InvoiceService(
IInvoiceRepository repo, IInvoiceRepository repo,
IShopPORepository shopPoRepo, IShopPORepository shopPoRepo,
SchemeService schemeService,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser) ICurrentUserService currentUser)
{ {
@@ -27,53 +25,20 @@ public class InvoiceService(
i.Items.Where(x => x.IsActive).Select(x => i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId, new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity, 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) => private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice); 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 // TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
// scheme rules ignore them. // and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
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.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items) private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{ {
var lines = items.ToList(); var billable = BillableTotal(items);
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it. invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
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);
invoice.TotalAmount = billable - invoice.DiscountAmount; 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. // 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) => public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new() new()
@@ -118,17 +83,13 @@ public class InvoiceService(
FK_ShopPOItemId = i.Id, FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId, FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity, Quantity = i.Quantity,
UnitPrice = i.UnitPrice, UnitPrice = i.UnitPrice
IsFree = i.IsFree,
FK_SchemeId = i.FK_SchemeId,
SchemeName = i.SchemeName
}).ToList(); }).ToList();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id) public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{ {
var invoice = await repo.GetByIdAsync(id); var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null; return invoice is null ? null : ToResponse(invoice);
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
} }
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) 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); ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity; item.Quantity = edit.Quantity;
} }
if (edit.UnitPrice is decimal price) if (edit.UnitPrice is decimal price) item.UnitPrice = price;
{
if (item.IsFree && price != 0m)
throw new InvalidOperationException("A free-issue line must stay at zero price");
item.UnitPrice = price;
}
} }
else if (edit.ItemId is Guid itemId) 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)"); throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
} }
var allLines = invoice.Items.Concat(newLines).ToList(); await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
await schemeService.ApplyInvoiceDiscountSelectionAsync(
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
await ApplyTotalsAsync(invoice, allLines);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
@@ -260,7 +213,7 @@ public class InvoiceService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!; return ToResponse((await repo.GetByIdAsync(id))!);
} }
private static void ValidateQuantity(InvoiceItem item, int quantity) private static void ValidateQuantity(InvoiceItem item, int quantity)
@@ -10,8 +10,6 @@ public interface ISchemeRepository
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId); Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity); 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> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<bool> HasLockedInvoiceAsync(Guid shopPOId); Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
@@ -112,14 +112,6 @@ public class SchemeController(SchemeService service) : ControllerBase
return result is null ? NotFound() : Ok(result); 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")] [HttpGet("{id:guid}/spend")]
[Authorize("scheme.view")] [Authorize("scheme.view")]
public async Task<IActionResult> GetSpend(Guid id) public async Task<IActionResult> GetSpend(Guid id)
+1 -7
View File
@@ -94,13 +94,7 @@ public record SchemeEligibilityResponse(
string? Reason, string? Reason,
decimal? DiscountAmount, decimal? DiscountAmount,
Guid? FreeItemId, Guid? FreeItemId,
decimal? FreeQty, 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);
public record SchemeRedemptionResponse( public record SchemeRedemptionResponse(
Guid Id, Guid Id,
@@ -49,18 +49,6 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) => public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(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) => public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId); await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
+10 -360
View File
@@ -1,7 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Distribution.Features.Invoice;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.ShopPO; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.Store; using Distribution.Features.Store;
@@ -16,8 +14,6 @@ public class SchemeService(
IItemRepository itemRepo, IItemRepository itemRepo,
IStoreRepository storeRepo, IStoreRepository storeRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -265,7 +261,12 @@ public class SchemeService(
foreach (var scheme in candidates) 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; continue;
result.Add(EvaluateRule(scheme, items)); result.Add(EvaluateRule(scheme, items));
@@ -274,328 +275,6 @@ public class SchemeService(
return result; 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> /// <summary>
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line /// 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)` /// 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."); return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
var today = DateTime.Now; 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 (false, "Scheme is not currently active (outside its date range).");
return (true, null); 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) switch (scheme.RuleType)
{ {
case SchemeRuleType.BuyXGetY: case SchemeRuleType.BuyXGetY:
@@ -739,10 +414,7 @@ public class SchemeService(
FK_ShopPOId = shopPOId, FK_ShopPOId = shopPOId,
FK_ItemId = freeItem.Id, FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value, Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m, UnitPrice = 0m
IsFree = true,
FK_SchemeId = scheme.Id,
SchemeName = scheme.Name
}); });
redemption.FK_FreeItemId = freeItem.Id; redemption.FK_FreeItemId = freeItem.Id;
@@ -753,27 +425,7 @@ public class SchemeService(
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
if (newItems.Count > 0) if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
{
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
}));
}
}
await repo.AddRedemptionAsync(redemption); await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
@@ -835,8 +487,6 @@ public class SchemeService(
if (freeItemId is null || freeQty is null or <= 0) if (freeItemId is null || freeQty is null or <= 0)
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required."); 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) if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
throw new InvalidOperationException("Free item not found."); throw new InvalidOperationException("Free item not found.");
} }
+5 -11
View File
@@ -2,18 +2,13 @@
namespace Distribution.Features.ShopPO; 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);
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null); public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0. public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
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 ConfirmShopPORequest(Guid? ConfirmedByUserId = null); public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = 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, 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 ShopPOResponse( public record ShopPOResponse(
Guid Id, Guid Id,
@@ -39,5 +34,4 @@ public record ShopPOResponse(
DateTime? CancelledAt = null, DateTime? CancelledAt = null,
string? CancelReason = null, string? CancelReason = null,
string? CancelledByName = null, string? CancelledByName = null,
string? CancelledByRole = null, string? CancelledByRole = null);
List<ShopPODiscountResponse>? Discounts = null);
+29 -46
View File
@@ -1,7 +1,6 @@
using Distribution.Features.Invoice; using Distribution.Features.Invoice;
using Distribution.Features.Notifications; using Distribution.Features.Notifications;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Scheme;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -12,30 +11,9 @@ public class ShopPOService(
IShopPORepository repo, IShopPORepository repo,
IInvoiceRepository invoiceRepo, IInvoiceRepository invoiceRepo,
PriceListService priceListService, PriceListService priceListService,
SchemeService schemeService,
INotificationService notifications, INotificationService notifications,
IUnitOfWork uow) 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. // 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) => private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
notifications.NotifyAsync( notifications.NotifyAsync(
@@ -44,15 +22,14 @@ public class ShopPOService(
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId), new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
ct); 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, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber, p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status, p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt, p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i => p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive, i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
p.IsActive, p.CreatedAt, p.IsActive, p.CreatedAt,
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null, users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : 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, users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
p.CancelledBy, p.CancelledAt, p.CancelReason, 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 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, users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
discounts ?? []);
private static IEnumerable<Guid> UserIds(Models.ShopPO p) private static IEnumerable<Guid> UserIds(Models.ShopPO p)
{ {
@@ -86,7 +62,7 @@ public class ShopPOService(
var po = await repo.GetByIdAsync(id); var po = await repo.GetByIdAsync(id);
if (po is null) return null; if (po is null) return null;
var users = await repo.GetUserInfoAsync(UserIds(po)); 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) 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) foreach (var line in request.Items)
{ {
var item = await BuildLineAsync(line, request.StoreId); var unitPrice = line.UnitPrice
items.Add(item); ?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
total += item.Quantity * item.UnitPrice; ?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
}
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items); items.Add(new ShopPOItem
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds); {
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
var po = new Models.ShopPO var po = new Models.ShopPO
{ {
@@ -128,7 +110,6 @@ public class ShopPOService(
await repo.AddAsync(po); await repo.AddAsync(po);
foreach (var item in items) item.FK_ShopPOId = po.Id; foreach (var item in items) item.FK_ShopPOId = po.Id;
await repo.AddItemsAsync(items); await repo.AddItemsAsync(items);
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
@@ -156,14 +137,20 @@ public class ShopPOService(
foreach (var line in request.Items) foreach (var line in request.Items)
{ {
var item = await BuildLineAsync(line, po.FK_StoreId); var unitPrice = line.UnitPrice
item.FK_ShopPOId = id; ?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
newItems.Add(item); ?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
total += item.Quantity * item.UnitPrice;
}
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems); newItems.Add(new ShopPOItem
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds); {
FK_ShopPOId = id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
po.TotalAmount = total; po.TotalAmount = total;
@@ -171,7 +158,6 @@ public class ShopPOService(
try try
{ {
await repo.ReplaceItemsAsync(id, newItems); await repo.ReplaceItemsAsync(id, newItems);
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
repo.Update(po); repo.Update(po);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
@@ -226,9 +212,6 @@ public class ShopPOService(
CreatedBy = userId, CreatedBy = userId,
TotalAmount = po.TotalAmount 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); await uow.BeginAsync(ct);
try 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") b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<Guid?>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ShopPOItemId") b.Property<Guid?>("FK_ShopPOItemId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<bool>("IsFree")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsRemoved") b.Property<bool>("IsRemoved")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<int>("Quantity") b.Property<int>("Quantity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("SchemeName")
.HasColumnType("longtext");
b.Property<decimal>("UnitPrice") b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
@@ -3726,27 +3717,18 @@ namespace Distribution.Migrations
b.Property<Guid>("FK_ItemId") b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<Guid?>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ShopPOId") b.Property<Guid>("FK_ShopPOId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<bool>("IsFree")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity") b.Property<int>("Quantity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("QuantityFulfilled") b.Property<int>("QuantityFulfilled")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("SchemeName")
.HasColumnType("longtext");
b.Property<decimal>("UnitPrice") b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
@@ -3766,7 +3748,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"), FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true, IsActive = true,
IsFree = false,
Quantity = 30, Quantity = 30,
QuantityFulfilled = 30, QuantityFulfilled = 30,
UnitPrice = 60m UnitPrice = 60m
@@ -3778,7 +3759,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"), FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true, IsActive = true,
IsFree = false,
Quantity = 24, Quantity = 24,
QuantityFulfilled = 24, QuantityFulfilled = 24,
UnitPrice = 50m UnitPrice = 50m
@@ -3790,7 +3770,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"), FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
IsActive = true, IsActive = true,
IsFree = false,
Quantity = 10, Quantity = 10,
QuantityFulfilled = 0, QuantityFulfilled = 0,
UnitPrice = 120m UnitPrice = 120m
@@ -3802,7 +3781,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"), FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
IsActive = true, IsActive = true,
IsFree = false,
Quantity = 20, Quantity = 20,
QuantityFulfilled = 20, QuantityFulfilled = 20,
UnitPrice = 120m UnitPrice = 120m
-5
View File
@@ -19,10 +19,5 @@ namespace Distribution.Models
public int Quantity { get; set; } public int Quantity { get; set; }
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
public bool IsRemoved { 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; }
} }
} }
-6
View File
@@ -15,11 +15,5 @@ namespace Distribution.Models
public int Quantity { get; set; } public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0; public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; } 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; }
} }
} }
+9 -90
View File
@@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem
SchemeRedemption SchemeRedemption
├── Id (Guid) — PK ├── Id (Guid) — PK
├── FK_SchemeId (Guid) — required ├── 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 ├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
│ entity exists yet, so this is an unconstrained Guid for now │ entity exists yet, so this is an unconstrained Guid for now
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions ├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
@@ -87,8 +87,8 @@ SchemeRedemption
│ later changes │ later changes
├── RedeemedAt (DateTime) ├── RedeemedAt (DateTime)
``` ```
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact). (a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
### Item (extended) ### 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 | | — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row | | — | `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 | | — | `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, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
| `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 |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts | | `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 | | `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` | | `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`. [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 (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.) 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 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 order's date falls within `[DateRangeFrom, DateRangeTo]`.
whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st). 3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`). 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 4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate. listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
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.
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`, 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 `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. 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 ## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual - 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 ├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity) ├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
├── UnitPrice (decimal) ├── UnitPrice (decimal)
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
└── IsActive, CreatedAt — from Template └── IsActive, CreatedAt — from Template
InvoiceVersion — immutable snapshot of the invoice 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!"). `*** 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`. - 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. - 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, discount, tax, due date.
- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date.
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a **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 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. also unresolved. Both cases need explicit cascade rules before implementation.
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments 6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines. (`{ 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 Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items
by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see given to the shop at no charge) is a known case that needs its own field(s) in the payload.
[17_promotions_schemes.md](../17_promotions_schemes.md).
--- ---
@@ -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&apos;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' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' 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 { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse, SchemeRuleType } from '@/types/api' 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) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) 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() { export function SchemesClient() {
const router = useRouter()
const [data, setData] = useState<SchemeResponse[]>([]) const [data, setData] = useState<SchemeResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [pageSize, setPageSize] = useState(20) 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 () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -49,15 +52,18 @@ export function SchemesClient() {
}, [fetchData]) }, [fetchData])
function openCreate() { function openCreate() {
router.push('/pricing/schemes/new') setEditing(null)
setSheetOpen(true)
} }
function openEdit(scheme: SchemeResponse) { function openEdit(scheme: SchemeResponse) {
router.push(`/pricing/schemes/${scheme.id}/edit`) setEditing(scheme)
setSheetOpen(true)
} }
function openView(scheme: SchemeResponse) { function openView(scheme: SchemeResponse) {
router.push(`/pricing/schemes/${scheme.id}`) setViewing(scheme)
setViewOpen(true)
} }
async function handleDelete(scheme: SchemeResponse) { async function handleDelete(scheme: SchemeResponse) {
@@ -85,14 +91,11 @@ export function SchemesClient() {
}, },
{ {
key: 'ruleType', key: 'ruleType',
title: 'Offer', title: 'Rule Type',
fields: ['ruleType'], fields: ['ruleType'],
minWidth: 280, width: 160,
cellRender: (row) => ( cellRender: (row) => (
<div className="flex flex-col gap-1"> <Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
<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>
), ),
}, },
{ {
@@ -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' } from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config' import type { InvoiceCreationMode } from '@/lib/api/sys-config'
import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api' import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
import { FreeIssuePanel, type FreeLine } from '../../_components/free-issue-panel'
const lineItemSchema = z.object({ const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'), itemId: z.string().min(1, 'Item is required'),
@@ -61,8 +60,6 @@ export default function EditShopPOPage() {
const [confirmedByUserId, setConfirmedByUserId] = useState('') const [confirmedByUserId, setConfirmedByUserId] = useState('')
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE) const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
const [discountIds, setDiscountIds] = useState<string[]>([])
useEffect(() => { useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {}) invoiceCreationModeApi.get().then(setMode).catch(() => {})
@@ -94,21 +91,9 @@ export default function EditShopPOPage() {
setUsersList(users) setUsersList(users)
form.reset({ form.reset({
items: poRes.items items: poRes.items
.filter((i) => i.isActive && !i.isFree) .filter((i) => i.isActive)
.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })), .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) => { .catch((err) => {
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' }) 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) { async function onSubmit(values: FormValues) {
try { try {
await shopPosApi.updateItems(id, { await shopPosApi.updateItems(id, { items: values.items })
discountSchemeIds: discountIds,
items: [
...values.items,
...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })),
],
})
toast.add({ title: 'Shop PO updated', type: 'success' }) toast.add({ title: 'Shop PO updated', type: 'success' })
router.push('/sales') router.push('/sales')
} catch (err) { } catch (err) {
@@ -317,20 +296,6 @@ export default function EditShopPOPage() {
</div> </div>
</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="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96"> <div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3> <h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -83,7 +83,7 @@ export default function ShopPOViewPage() {
if (!po) return null 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -155,7 +155,6 @@ export default function ShopPOViewPage() {
<tr> <tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th> <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">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-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">Unit Price</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</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> <tbody>
{activeItems.length === 0 ? ( {activeItems.length === 0 ? (
<tr> <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. No items on this PO.
</td> </td>
</tr> </tr>
) : ( ) : (
activeItems.map((item) => ( activeItems.map((item) => (
<tr key={item.id} className="border-t"> <tr key={item.id} className="border-t">
<td className="px-3 py-2"> <td className="px-3 py-2">{item.itemName}</td>
{item.itemName} <td className="px-3 py-2 text-right">{item.quantity}</td>
{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 text-right">{item.quantityFulfilled}</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">{fmtAmount(item.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * 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> </tbody>
{activeItems.length > 0 && ( {activeItems.length > 0 && (
<tfoot> <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"> <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 Total
</td> </td>
<td className="px-3 py-3 text-right text-base font-semibold text-foreground"> <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 &amp; 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)} &rarr; {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, invoices as invoicesApi,
users as usersApi, users as usersApi,
items as itemsApi, items as itemsApi,
schemes as schemesApi,
priceLists as priceListsApi, priceLists as priceListsApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } 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 { interface AddedLine {
key: string key: string
@@ -60,11 +59,6 @@ export default function InvoiceEditPage() {
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [saving, setSaving] = useState(false) 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(() => { useEffect(() => {
invoicesApi.get(invoiceId) invoicesApi.get(invoiceId)
@@ -75,7 +69,6 @@ export default function InvoiceEditPage() {
return return
} }
setFull(inv) setFull(inv)
setDiscountIds(inv.discounts.map((d) => d.schemeId))
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)]))) setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)]))) setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved]))) setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
@@ -111,65 +104,11 @@ export default function InvoiceEditPage() {
quantity: Number(a.qty) || 0, quantity: Number(a.qty) || 0,
unitPrice: Number(a.price) || 0, unitPrice: Number(a.price) || 0,
isRemoved: false, isRemoved: false,
isFree: false,
schemeId: null,
schemeName: null,
})) }))
const items = [...existing, ...extra] const items = [...existing, ...extra]
const subtotal = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) const totalAmount = 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, totalAmount }
return { ...full, items, discountAmount, totalAmount: subtotal - discountAmount } }, [full, qty, price, removed, added])
}, [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])
// Items not already on the invoice (a deleted line can be restored with Undo instead). // 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)) 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) })), ...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
], ],
userId, userId,
discountIds,
) )
toast.add({ title: 'Invoice updated', type: 'success' }) toast.add({ title: 'Invoice updated', type: 'success' })
// Navigating away right after a successful save; clear the unsaved-changes guard first. // 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> <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 <InvoiceTemplate
invoice={live} invoice={live}
renderQuantity={(item) => ( renderQuantity={(item) => (
@@ -340,9 +248,7 @@ export default function InvoiceEditPage() {
: setQty((q) => ({ ...q, [item.id]: e.target.value }))} : setQty((q) => ({ ...q, [item.id]: e.target.value }))}
/> />
)} )}
renderUnitPrice={(item) => item.isFree ? ( renderUnitPrice={(item) => (
<span className="text-sm text-muted-foreground">Free</span>
) : (
<Input <Input
type="number" min={0} step="0.01" type="number" min={0} step="0.01"
className="ml-auto h-8 w-28 text-right" className="ml-auto h-8 w-28 text-right"
@@ -424,7 +330,7 @@ export default function InvoiceEditPage() {
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button> <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'} {saving ? 'Saving…' : 'Save changes'}
</Button> </Button>
</div> </div>
@@ -199,7 +199,6 @@ export default function InvoiceViewPage() {
<th className="px-3 py-2 text-left font-medium">Item</th> <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">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">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-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> <th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
</tr> </tr>
@@ -207,23 +206,19 @@ export default function InvoiceViewPage() {
<tbody> <tbody>
{invoice.items.length === 0 ? ( {invoice.items.length === 0 ? (
<tr> <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> </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' : ''}`}> <tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
<td className="px-3 py-2"> <td className="px-3 py-2">
{i.itemName} {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.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>} {i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
</td> </td>
<td className="px-3 py-2 text-right">{i.isFree ? '-' : (i.orderedQuantity ?? '-')}</td> <td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td>
<td className={`px-3 py-2 text-right ${!i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}> <td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.isFree ? '-' : i.quantity} {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> </td>
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</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> <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> </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> </table>
</div> </div>
</div> </div>
@@ -29,7 +29,6 @@ import {
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api' import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api'
import { FreeIssuePanel, type FreeLine } from '../_components/free-issue-panel'
const lineItemSchema = z.object({ const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'), itemId: z.string().min(1, 'Item is required'),
@@ -58,8 +57,6 @@ export default function NewShopPOPage() {
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({}) const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
const [priceRetailByIndex, setPriceRetailByIndex] = 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>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -128,13 +125,9 @@ export default function NewShopPOPage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await shopPosApi.create({ await shopPosApi.create({
discountSchemeIds: discountIds,
storeId: values.storeId, storeId: values.storeId,
raisedByUserId: values.raisedByUserId, raisedByUserId: values.raisedByUserId,
items: [ items: values.items,
...values.items,
...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })),
],
}) })
toast.add({ title: 'Shop PO created', type: 'success' }) toast.add({ title: 'Shop PO created', type: 'success' })
router.push('/sales') router.push('/sales')
@@ -350,19 +343,6 @@ export default function NewShopPOPage() {
</div> </div>
</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="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96"> <div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3> <h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -1,8 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' 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 { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field' import { FormField } from '@/components/shared/form-field'
import { import {
@@ -166,14 +164,8 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
/> />
</td> </td>
<td className="px-3 py-2 text-right no-underline"> <td className="px-3 py-2 text-right no-underline">
<Button <Button type="button" variant="ghost" size="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}>
type="button" variant="ghost" size="icon-sm" {removed[i.id] ? 'Undo' : 'Remove'}
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> </Button>
</td> </td>
</tr> </tr>
@@ -1,117 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { roles as rolesApi, getApiError } from '@/lib/api/index'
import { PERMISSION_CATEGORIES } from '@/config/permissions'
import type { RoleResponse, PermissionResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
// 'orders.view' -> 'View Orders', 'loading-sheet.manage' -> 'Manage Loading Sheet'
function permissionLabel(code: string): string {
const [resource, action] = code.split('.')
const title = (s: string) => s.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
return action ? `${title(action)} ${title(resource)}` : title(resource)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{children}</span>
</div>
)
}
export default function ViewRolePage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const goBack = () => router.push('/users/roles')
const [role, setRole] = useState<RoleResponse | null>(null)
const [permissions, setPermissions] = useState<PermissionResponse[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([rolesApi.get(id), rolesApi.getPermissions(id)])
.then(([r, perms]) => { setRole(r); setPermissions(perms) })
.catch((err) => toast.add({ title: 'Failed to load role', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [id])
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Role Details</h1>
<p className="text-sm text-muted-foreground mt-0.5">View role details and permissions.</p>
</div>
</div>
{role && (
<Button onClick={() => router.push(`/users/roles/${role.id}/edit`)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} />
Edit Role
</Button>
)}
</div>
<div className="rounded-lg border bg-card p-6">
{loading && (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
))}
</div>
)}
{!loading && role && (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Field label="Role Name">{role.name}</Field>
<Field label="Level">{role.level}</Field>
<Field label="Status">
<Badge variant={role.isActive ? 'default' : 'secondary'}>{role.isActive ? 'Active' : 'Inactive'}</Badge>
</Field>
<Field label="Created At">{fmtDate(role.createdAt)}</Field>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Permissions</p>
{permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No permissions assigned to this role.</p>
) : (
<div className="border rounded-md p-4 flex flex-col gap-3">
{PERMISSION_CATEGORIES.map((cat) => {
const catPerms = permissions.filter((p) => cat.codes.includes(p.code))
if (catPerms.length === 0) return null
return (
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{cat.label}</span>
<div className="flex flex-row flex-wrap gap-x-6 gap-y-2">
{catPerms.map((p) => (
<span key={p.permissionId} className="text-sm font-medium">{permissionLabel(p.code)}</span>
))}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { roles as rolesApi, getApiError } from '@/lib/api/index'
import type { RoleResponse, PermissionResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface RoleViewSheetProps {
open: boolean
onClose: () => void
role: RoleResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between border-b py-2 text-sm last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function RoleViewSheet({ open, onClose, role }: RoleViewSheetProps) {
const [permissions, setPermissions] = useState<PermissionResponse[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open || !role) return
setLoading(true)
rolesApi.getPermissions(role.id)
.then(setPermissions)
.catch((err) => toast.add({ title: 'Failed to load permissions', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [open, role])
if (!role) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Role Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Name" value={role.name} />
<Row label="Level" value={role.level} />
<Row label="Status" value={<Badge variant={role.isActive ? 'default' : 'secondary'}>{role.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Created At" value={fmtDate(role.createdAt)} />
</div>
<div className="mt-2 flex flex-col gap-2">
<p className="text-sm font-medium">Permissions</p>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No permissions assigned to this role.</p>
) : (
<div className="max-h-64 overflow-y-auto rounded-md border p-3 flex flex-col gap-2">
{permissions.map((p) => (
<div key={p.permissionId} className="flex flex-col">
<span className="text-sm font-medium">{p.code}</span>
{p.description && <span className="text-xs text-muted-foreground">{p.description}</span>}
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
@@ -13,6 +13,8 @@ import { toast } from '@/components/ui/toast'
import { roles as rolesApi, getApiError } from '@/lib/api/index' import { roles as rolesApi, getApiError } from '@/lib/api/index'
import type { RoleResponse } from '@/types/api' import type { RoleResponse } from '@/types/api'
import { RoleViewSheet } from './role-view-sheet'
export function RolesClient() { export function RolesClient() {
const router = useRouter() const router = useRouter()
const [data, setData] = useState<RoleResponse[]>([]) const [data, setData] = useState<RoleResponse[]>([])
@@ -20,6 +22,9 @@ export function RolesClient() {
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [viewOpen, setViewOpen] = useState(false)
const [viewing, setViewing] = useState<RoleResponse | null>(null)
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -37,7 +42,7 @@ export function RolesClient() {
function openCreate() { router.push('/users/roles/new') } function openCreate() { router.push('/users/roles/new') }
function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) } function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) }
function openView(role: RoleResponse) { router.push(`/users/roles/${role.id}`) } function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
async function handleDeactivate(role: RoleResponse) { async function handleDeactivate(role: RoleResponse) {
try { try {
@@ -115,6 +120,12 @@ export function RolesClient() {
totalCount={total} totalCount={total}
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)} onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
/> />
<RoleViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
role={viewing}
/>
</> </>
) )
} }
@@ -34,11 +34,9 @@ const statusLabel = (invoice: InvoiceResponse) =>
// Removed lines stay on the document (struck through) but are excluded from every total. // Removed lines stay on the document (struck through) but are excluded from every total.
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) { function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
const subtotal = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) const total = 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 paid = payments.reduce((s, p) => s + p.amount, 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) { export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
@@ -58,12 +56,9 @@ function A4Sheet({
showAgentEdits, showAgentEdits,
className, className,
}: Omit<InvoiceTemplateProps, 'paper'>) { }: Omit<InvoiceTemplateProps, 'paper'>) {
const { subtotal, discount, total, paid, balance } = totals(invoice, payments) const { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice) const status = statusLabel(invoice)
const hasRemoved = invoice.items.some((i) => i.isRemoved) 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 ( return (
<div <div
@@ -112,7 +107,6 @@ function A4Sheet({
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500"> <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="px-2 py-2 text-left font-medium">Item</th>
<th className="w-24 px-2 py-2 text-right font-medium">Qty</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-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> <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" />} {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> <td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
</tr> </tr>
) : ( ) : (
rows.map((item) => ( invoice.items.map((item) => (
<tr <tr
key={item.id} key={item.id}
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')} className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
> >
<td className="px-2 py-2.5"> <td className="px-2 py-2.5">{item.itemName}</td>
{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 text-right"> <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> </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"> <td className="px-2 py-2.5 text-right">
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)} {renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
</td> </td>
@@ -176,14 +160,8 @@ function A4Sheet({
<dl className="w-72 break-inside-avoid text-sm"> <dl className="w-72 break-inside-avoid text-sm">
<div className="flex justify-between border-b py-1.5"> <div className="flex justify-between border-b py-1.5">
<dt className="text-neutral-500">Subtotal</dt> <dt className="text-neutral-500">Subtotal</dt>
<dd>{fmtMoney(subtotal)}</dd> <dd>{fmtMoney(total)}</dd>
</div> </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 }}> <div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
<dt>Total</dt> <dt>Total</dt>
<dd>{fmtMoney(total)}</dd> <dd>{fmtMoney(total)}</dd>
@@ -247,7 +225,7 @@ function ReceiptSheet({
showAgentEdits, showAgentEdits,
className, className,
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) { }: 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 status = statusLabel(invoice)
const narrow = paper === '58mm' const narrow = paper === '58mm'
const rule = <div className="my-2 border-t border-dashed border-black" /> const rule = <div className="my-2 border-t border-dashed border-black" />
@@ -274,21 +252,12 @@ function ReceiptSheet({
</div> </div>
{rule} {rule}
<div className="space-y-1.5"> <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')}> <div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
<p>{i.itemName}</p> <p>{i.itemName}</p>
<p className="flex justify-between"> <p className="flex justify-between">
{i.isFree ? ( <span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
<> <span>{num(i.quantity * i.unitPrice)}</span>
<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>
</>
)}
</p> </p>
</div> </div>
))} ))}
@@ -296,14 +265,8 @@ function ReceiptSheet({
{rule} {rule}
<p className="flex justify-between"> <p className="flex justify-between">
<span>Subtotal</span> <span>Subtotal</span>
<span>{fmtMoney(subtotal)}</span> <span>{fmtMoney(total)}</span>
</p> </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"> <div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
<p className="flex justify-between"> <p className="flex justify-between">
<span>TOTAL</span> <span>TOTAL</span>
-14
View File
@@ -4,7 +4,6 @@ import type {
PriceResolution, PriceResolution,
SchemeResponse, SchemeResponse,
SchemeRuleType, SchemeRuleType,
SchemeSuggestion,
} from '@/types/api' } from '@/types/api'
// ─── Price Lists ────────────────────────────────────────────────────────────── // ─── Price Lists ──────────────────────────────────────────────────────────────
@@ -90,17 +89,4 @@ export const schemes = {
remove: (id: string) => remove: (id: string) =>
api.delete(`/schemes/${id}`), 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),
} }
+2 -12
View File
@@ -21,22 +21,16 @@ export interface ShopPOItemBody {
itemId: string itemId: string
quantity: number quantity: number
unitPrice?: number | null unitPrice?: number | null
/** Free issue accepted from a scheme suggestion; priced 0 by the server. */
isFree?: boolean
schemeId?: string | null
} }
export interface CreateShopPOBody { export interface CreateShopPOBody {
storeId: string storeId: string
items: ShopPOItemBody[] items: ShopPOItemBody[]
raisedByUserId?: string | null raisedByUserId?: string | null
/** The one flat/slab discount scheme the user accepted. */
discountSchemeIds?: string[]
} }
export interface UpdateShopPOItemsBody { export interface UpdateShopPOItemsBody {
items: ShopPOItemBody[] items: ShopPOItemBody[]
discountSchemeIds?: string[]
} }
export interface ShopPOListParams extends PageParams { 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 }[]) => 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), 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[]) => 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, discountSchemeIds }).then(r => r.data), api.put<InvoiceResponse>(`/invoices/${id}/items`, { items, editedByUserId }).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),
versions: (id: string) => versions: (id: string) =>
api.get<InvoiceVersionResponse[]>(`/invoices/${id}/versions`).then(r => r.data), api.get<InvoiceVersionResponse[]>(`/invoices/${id}/versions`).then(r => r.data),
-26
View File
@@ -661,18 +661,6 @@ export interface SchemeResponse {
createdAt: string 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 ──────────────────────────────────────────────────────────────── // ─── Suppliers ────────────────────────────────────────────────────────────────
export interface SupplierResponse { export interface SupplierResponse {
@@ -985,15 +973,6 @@ export interface ShopPOItemResponse {
quantityFulfilled: number quantityFulfilled: number
unitPrice: number unitPrice: number
isActive: boolean isActive: boolean
isFree: boolean
schemeId: string | null
schemeName: string | null
}
export interface ShopPODiscount {
schemeId: string
schemeName: string
amount: number
} }
export interface ShopPOResponse { export interface ShopPOResponse {
@@ -1021,7 +1000,6 @@ export interface ShopPOResponse {
cancelReason: string | null cancelReason: string | null
cancelledByName: string | null cancelledByName: string | null
cancelledByRole: string | null cancelledByRole: string | null
discounts: ShopPODiscount[]
} }
export interface InvoiceItemResponse { export interface InvoiceItemResponse {
@@ -1033,9 +1011,6 @@ export interface InvoiceItemResponse {
quantity: number quantity: number
unitPrice: number unitPrice: number
isRemoved: boolean isRemoved: boolean
isFree: boolean
schemeId: string | null
schemeName: string | null
} }
export interface InvoiceVersionItemResponse { export interface InvoiceVersionItemResponse {
@@ -1075,7 +1050,6 @@ export interface InvoiceResponse {
isActive: boolean isActive: boolean
createdAt: string createdAt: string
items: InvoiceItemResponse[] items: InvoiceItemResponse[]
discounts: ShopPODiscount[]
} }
export interface OutstandingPaymentsResponse { export interface OutstandingPaymentsResponse {