Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| a80512994b | |||
| 2de9e5e4ad |
@@ -0,0 +1,209 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class NotificationServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<INotificationRepository> _repo = new();
|
||||||
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||||
|
private readonly Mock<IHubClients> _clients = new();
|
||||||
|
private readonly Mock<IClientProxy> _proxy = new();
|
||||||
|
private readonly NotificationService _sut;
|
||||||
|
|
||||||
|
public NotificationServiceTests()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||||
|
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||||
|
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||||
|
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||||
|
|
||||||
|
_sut = new NotificationService(
|
||||||
|
_repo.Object,
|
||||||
|
new NotificationRecipientResolver(_repo.Object),
|
||||||
|
_hub.Object,
|
||||||
|
_uow.Object,
|
||||||
|
NullLogger<NotificationService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationArgs Args(Guid? actor = null) =>
|
||||||
|
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||||
|
|
||||||
|
// Registry
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryNotificationTypeConstant_HasADefinition()
|
||||||
|
{
|
||||||
|
var constants = typeof(NotificationTypes)
|
||||||
|
.GetFields()
|
||||||
|
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||||
|
.Select(f => (string)f.GetRawConstantValue()!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(constants);
|
||||||
|
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||||
|
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||||
|
{
|
||||||
|
var actor = Guid.NewGuid();
|
||||||
|
var other = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||||
|
[RecipientRule.Users(actor, other)], Args(actor));
|
||||||
|
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
var recipient = Assert.Single(saved!.Recipients);
|
||||||
|
Assert.Equal(other, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||||
|
{
|
||||||
|
var a = Guid.NewGuid();
|
||||||
|
var b = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||||
|
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||||
|
{
|
||||||
|
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||||
|
{
|
||||||
|
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient rules
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||||
|
{
|
||||||
|
var both = Guid.NewGuid();
|
||||||
|
var permissionOnly = Guid.NewGuid();
|
||||||
|
var scopeOnly = Guid.NewGuid();
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||||
|
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||||
|
|
||||||
|
Assert.Equal([both], ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
var supervisor = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||||
|
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||||
|
{
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||||
|
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||||
|
{
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||||
|
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||||
|
|
||||||
|
Assert.Equal([user], ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<Invoice> Invoices { get; set; }
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
|
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||||
|
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||||
|
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
@@ -71,6 +74,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||||
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -173,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ShopPOId)
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Invoice)
|
||||||
|
.WithMany(inv => inv.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasOne(v => v.Invoice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.InvoiceVersion)
|
||||||
|
.WithMany(v => v.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Invoice>()
|
modelBuilder.Entity<Invoice>()
|
||||||
.HasMany(i => i.Payments)
|
.HasMany(i => i.Payments)
|
||||||
.WithOne(p => p.Invoice)
|
.WithOne(p => p.Invoice)
|
||||||
@@ -708,6 +761,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
.HasIndex(a => a.FK_GoodsReceiptId);
|
.HasIndex(a => a.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<Notification>()
|
||||||
|
.HasIndex(n => new { n.EntityType, n.EntityId });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.Notification)
|
||||||
|
.WithMany(n => n.Recipients)
|
||||||
|
.HasForeignKey(r => r.FK_NotificationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_UserId, r.IsRead });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
// --- StoreCreditMovement ---
|
// --- StoreCreditMovement ---
|
||||||
|
|
||||||
modelBuilder.Entity<StoreCreditMovement>()
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
|||||||
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
|
|
||||||
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||||
|
|
||||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||||
|
|
||||||
public record SalesKpis(
|
public record SalesKpis(
|
||||||
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||||
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||||
|
|
||||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||||
|
|
||||||
|
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
|
||||||
|
|
||||||
|
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
|
||||||
|
|
||||||
|
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
|
||||||
|
|
||||||
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||||
|
|
||||||
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||||
|
|
||||||
public record SalesSummaryResponse(
|
public record SalesSummaryResponse(
|
||||||
DateTime From, DateTime To, string Granularity,
|
DateTime From, DateTime To, string Granularity,
|
||||||
SalesKpiComparison Kpis, List<TrendPoint> Trend,
|
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||||
|
|
||||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||||
|
|||||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
public Guid RepId { get; set; }
|
public Guid RepId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||||
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||||
|
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
|
||||||
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var start = f.From.Date;
|
var start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
var end = f.To.Date.AddDays(1);
|
||||||
var q =
|
var q =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
|
||||||
select new SoldLine
|
select new SoldLine
|
||||||
{
|
{
|
||||||
Date = inv.FinalizedAt!.Value.Date,
|
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||||
PoId = li.FK_ShopPOId,
|
PoId = li.Invoice.FK_ShopPOId,
|
||||||
Value = li.QuantityFulfilled * li.UnitPrice,
|
Value = li.Quantity * li.UnitPrice,
|
||||||
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
|
||||||
CategoryId = li.Item.FK_CategoryId,
|
CategoryId = li.Item.FK_CategoryId,
|
||||||
CategoryName = li.Item.Category.Name,
|
CategoryName = li.Item.Category.Name,
|
||||||
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||||
RegionName = li.ShopPO.Store.Route.Region.Name,
|
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||||
RepId = li.ShopPO.RaisedById,
|
RepId = li.Invoice.ShopPO.RaisedById,
|
||||||
};
|
};
|
||||||
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||||
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||||
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
{
|
{
|
||||||
var start = f.From.Date;
|
var start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
var end = f.To.Date.AddDays(1);
|
||||||
|
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
|
||||||
var q = db.SaleReturnItems.AsNoTracking()
|
var q = db.SaleReturnItems.AsNoTracking()
|
||||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
|
||||||
|
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
|
||||||
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
return q;
|
return q;
|
||||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||||
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||||
return new SalesTotals(gross, returns, orders, costedSales, cost);
|
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
|
||||||
|
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
|
||||||
|
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
if (f.CategoryId.HasValue) return 0m;
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
|
||||||
|
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
|
||||||
|
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
|
||||||
|
private const int OverdueDays = 30;
|
||||||
|
|
||||||
|
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
|
||||||
|
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
|
||||||
|
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var rows = await invs.Select(i => new
|
||||||
|
{
|
||||||
|
i.FinalizedAt,
|
||||||
|
i.TotalAmount,
|
||||||
|
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||||
|
.Sum(p => (decimal?)p.Amount) ?? 0m,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
var balances = rows
|
||||||
|
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
|
||||||
|
.ToList();
|
||||||
|
return new ReceivablesTotals(collected,
|
||||||
|
balances.Sum(b => b.Balance),
|
||||||
|
balances.Where(b => b.Due < end).Sum(b => b.Balance));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
public class DashboardReportService(IDashboardReportRepository repo)
|
public class DashboardReportService(IDashboardReportRepository repo)
|
||||||
{
|
{
|
||||||
public const int MaxRangeDays = 366;
|
public const int MaxRangeDays = 366;
|
||||||
private const int DailyGranularityMaxDays = 62;
|
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
|
||||||
|
|
||||||
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||||
{
|
{
|
||||||
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
|
|
||||||
private static SalesKpis ToKpis(SalesTotals t)
|
private static SalesKpis ToKpis(SalesTotals t)
|
||||||
{
|
{
|
||||||
var net = t.GrossSales - t.ReturnValue;
|
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||||
var gp = t.CostedSales - t.CostOfSales;
|
var gp = t.CostedSales - t.CostOfSales;
|
||||||
return new SalesKpis(
|
return new SalesKpis(
|
||||||
t.GrossSales, t.ReturnValue, net, t.Orders,
|
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
|
||||||
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||||
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
|
||||||
|
new(r.Collected, r.Outstanding, r.Overdue,
|
||||||
|
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
|
||||||
|
|
||||||
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var days = (f.To - f.From).Days + 1;
|
var days = (f.To - f.From).Days + 1;
|
||||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
|
|
||||||
var current = await repo.GetSalesTotalsAsync(f);
|
var current = await repo.GetSalesTotalsAsync(f);
|
||||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||||
|
var currentCash = await repo.GetReceivablesAsync(f);
|
||||||
|
var previousCash = await repo.GetReceivablesAsync(prev);
|
||||||
var sales = await repo.GetDailySalesAsync(f);
|
var sales = await repo.GetDailySalesAsync(f);
|
||||||
var returns = await repo.GetDailyReturnsAsync(f);
|
var returns = await repo.GetDailyReturnsAsync(f);
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
return new SalesSummaryResponse(
|
return new SalesSummaryResponse(
|
||||||
f.From, f.To, daily ? "day" : "month",
|
f.From, f.To, daily ? "day" : "month",
|
||||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||||
|
new CashKpiComparison(
|
||||||
|
ToCash(currentCash, current.GrossSales - current.Discounts),
|
||||||
|
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
|
||||||
trend,
|
trend,
|
||||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||||
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
|||||||
public interface IDashboardReportRepository
|
public interface IDashboardReportRepository
|
||||||
{
|
{
|
||||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||||
|
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
|||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
ILoadingSheetRepository loadingSheetRepo,
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string DeliveriesLink = "/operations/deliveries";
|
||||||
|
|
||||||
|
// Route owner + managers whose scope covers the store being delivered to.
|
||||||
|
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||||
|
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||||
|
];
|
||||||
|
|
||||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (!fullyDelivered && shopPO.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||||
|
DeliveryRecipients(shopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(taskId);
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (task.ShopPO?.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskFailed,
|
||||||
|
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(task);
|
return ToResponse(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ public interface IInvoiceRepository
|
|||||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||||
|
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||||
Task AddAsync(Models.Invoice invoice);
|
Task AddAsync(Models.Invoice invoice);
|
||||||
|
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||||
|
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||||
|
Task AddVersionAsync(InvoiceVersion version);
|
||||||
|
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||||
|
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||||
void Update(Models.Invoice invoice);
|
void Update(Models.Invoice invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/preview-totals")]
|
||||||
|
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||||
|
{
|
||||||
|
var result = await service.PreviewTotalsAsync(id, request);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/versions")]
|
||||||
|
public async Task<IActionResult> GetVersions(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetVersionsAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/items")]
|
||||||
|
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/print")]
|
[HttpPost("{id:guid}/print")]
|
||||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Features.ShopPO;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Invoice;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||||
|
|
||||||
|
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||||
|
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||||
|
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||||
|
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
|
||||||
|
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||||
|
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||||
|
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||||
|
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||||
|
|
||||||
|
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||||
|
|
||||||
|
public record InvoiceVersionResponse(
|
||||||
|
Guid Id,
|
||||||
|
int VersionNumber,
|
||||||
|
InvoiceVersionStage Stage,
|
||||||
|
decimal TotalAmount,
|
||||||
|
Guid CreatedBy,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceVersionItemResponse> Items);
|
||||||
|
|
||||||
|
public record InvoiceItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid? ShopPOItemId,
|
||||||
|
Guid ItemId,
|
||||||
|
string ItemName,
|
||||||
|
int? OrderedQuantity,
|
||||||
|
int Quantity,
|
||||||
|
decimal UnitPrice,
|
||||||
|
bool IsRemoved,
|
||||||
|
bool IsFree = false,
|
||||||
|
Guid? SchemeId = null,
|
||||||
|
string? SchemeName = null);
|
||||||
|
|
||||||
public record InvoiceResponse(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ShopPOId,
|
Guid ShopPOId,
|
||||||
@@ -20,8 +56,11 @@ public record InvoiceResponse(
|
|||||||
DateTime? FinalizedAt,
|
DateTime? FinalizedAt,
|
||||||
Guid? FinalizedBy,
|
Guid? FinalizedBy,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
decimal DiscountAmount,
|
||||||
decimal PaidAmount,
|
decimal PaidAmount,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceItemResponse> Items,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|
||||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||||
|
|
||||||
@@ -49,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||||
|
await context.SchemeRedemptions
|
||||||
|
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||||
|
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||||
|
|
||||||
public async Task AddAsync(Models.Invoice invoice) =>
|
public async Task AddAsync(Models.Invoice invoice) =>
|
||||||
await context.Invoices.AddAsync(invoice);
|
await context.Invoices.AddAsync(invoice);
|
||||||
|
|
||||||
|
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||||
|
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||||
|
|
||||||
|
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||||
|
await context.InvoiceItems.AddRangeAsync(items);
|
||||||
|
|
||||||
|
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||||
|
await context.InvoiceVersions.AddAsync(version);
|
||||||
|
|
||||||
|
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||||
|
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||||
|
|
||||||
|
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||||
|
.OrderBy(v => v.VersionNumber)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public void Update(Models.Invoice invoice) =>
|
public void Update(Models.Invoice invoice) =>
|
||||||
context.Invoices.Update(invoice);
|
context.Invoices.Update(invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Scheme;
|
||||||
|
using Distribution.Features.ShopPO;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -7,6 +9,8 @@ namespace Distribution.Features.Invoice;
|
|||||||
|
|
||||||
public class InvoiceService(
|
public class InvoiceService(
|
||||||
IInvoiceRepository repo,
|
IInvoiceRepository repo,
|
||||||
|
IShopPORepository shopPoRepo,
|
||||||
|
SchemeService schemeService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser)
|
ICurrentUserService currentUser)
|
||||||
{
|
{
|
||||||
@@ -17,14 +21,114 @@ public class InvoiceService(
|
|||||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||||
i.PrintedAt, i.PrintedBy,
|
i.PrintedAt, i.PrintedBy,
|
||||||
i.FinalizedAt, i.FinalizedBy,
|
i.FinalizedAt, i.FinalizedBy,
|
||||||
i.TotalAmount,
|
i.TotalAmount, i.DiscountAmount,
|
||||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||||
i.IsActive, i.CreatedAt);
|
i.IsActive, i.CreatedAt,
|
||||||
|
i.Items.Where(x => x.IsActive).Select(x =>
|
||||||
|
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||||
|
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||||
|
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||||
|
|
||||||
|
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||||
|
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
|
||||||
|
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||||
|
// scheme rules ignore them.
|
||||||
|
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||||
|
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||||
|
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||||
|
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||||
|
// and at print/finalize. The redemption amount is updated with it.
|
||||||
|
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||||
|
{
|
||||||
|
var lines = items.ToList();
|
||||||
|
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||||
|
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||||
|
var billable = BillableTotal(lines);
|
||||||
|
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||||
|
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||||
|
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||||
|
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id);
|
||||||
|
if (invoice is null) return null;
|
||||||
|
|
||||||
|
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||||
|
.ToList();
|
||||||
|
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||||
|
var chosen = request.DiscountSchemeIds is null
|
||||||
|
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||||
|
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||||
|
var discount = Math.Min(chosen, billable);
|
||||||
|
|
||||||
|
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||||
|
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||||
|
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||||
|
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable copy of the invoice lines as they stand right now.
|
||||||
|
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoice.Id,
|
||||||
|
VersionNumber = versionNumber,
|
||||||
|
Stage = stage,
|
||||||
|
TotalAmount = invoice.TotalAmount,
|
||||||
|
CreatedBy = userId,
|
||||||
|
Items = items.Select(i => new InvoiceVersionItem
|
||||||
|
{
|
||||||
|
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||||
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = i.UnitPrice,
|
||||||
|
IsRemoved = i.IsRemoved
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
|
||||||
|
{
|
||||||
|
if (await repo.GetByIdAsync(id) is null) return null;
|
||||||
|
var versions = await repo.GetVersionsAsync(id);
|
||||||
|
return versions.Select(v => new InvoiceVersionResponse(
|
||||||
|
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
|
||||||
|
v.Items.Select(i => new InvoiceVersionItemResponse(
|
||||||
|
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
|
||||||
|
{
|
||||||
|
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
|
||||||
|
// Added lines may or may not be in invoice.Items yet, so merge by reference.
|
||||||
|
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
|
||||||
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
|
||||||
|
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoiceId,
|
||||||
|
FK_ShopPOItemId = i.Id,
|
||||||
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = i.UnitPrice,
|
||||||
|
IsFree = i.IsFree,
|
||||||
|
FK_SchemeId = i.FK_SchemeId,
|
||||||
|
SchemeName = i.SchemeName
|
||||||
|
}).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);
|
||||||
return invoice is null ? null : ToResponse(invoice);
|
if (invoice is null) return null;
|
||||||
|
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)
|
||||||
@@ -42,6 +146,131 @@ public class InvoiceService(
|
|||||||
return new OutstandingPaymentsResponse(count, outstanding);
|
return new OutstandingPaymentsResponse(count, outstanding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||||
|
?? throw new KeyNotFoundException("Shop PO not found");
|
||||||
|
|
||||||
|
if (po.ConfirmedAt is null)
|
||||||
|
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||||
|
if (po.Status == ShopPOStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||||
|
if (po.InvoiceId is not null)
|
||||||
|
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||||
|
|
||||||
|
var invoice = new Models.Invoice
|
||||||
|
{
|
||||||
|
FK_ShopPOId = po.Id,
|
||||||
|
Status = InvoiceStatus.Draft,
|
||||||
|
PaymentStatus = PaymentStatus.Pending,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(invoice);
|
||||||
|
await repo.AddItemsAsync(lines);
|
||||||
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||||
|
po.InvoiceId = invoice.Id;
|
||||||
|
shopPoRepo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Invoice not found");
|
||||||
|
|
||||||
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
|
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||||
|
|
||||||
|
var newLines = new List<InvoiceItem>();
|
||||||
|
|
||||||
|
foreach (var edit in request.Items)
|
||||||
|
{
|
||||||
|
if (edit.UnitPrice is < 0)
|
||||||
|
throw new InvalidOperationException("Unit price cannot be negative");
|
||||||
|
|
||||||
|
if (edit.InvoiceItemId is Guid lineId)
|
||||||
|
{
|
||||||
|
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||||
|
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||||
|
|
||||||
|
item.IsRemoved = edit.IsRemoved;
|
||||||
|
if (!edit.IsRemoved)
|
||||||
|
{
|
||||||
|
ValidateQuantity(item, edit.Quantity);
|
||||||
|
item.Quantity = edit.Quantity;
|
||||||
|
}
|
||||||
|
if (edit.UnitPrice is decimal price)
|
||||||
|
{
|
||||||
|
if (item.IsFree && price != 0m)
|
||||||
|
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||||
|
item.UnitPrice = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (edit.ItemId is Guid itemId)
|
||||||
|
{
|
||||||
|
// New line for an item that was not on the PO.
|
||||||
|
if (edit.IsRemoved) continue;
|
||||||
|
if (edit.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
||||||
|
if (edit.UnitPrice is null)
|
||||||
|
throw new InvalidOperationException("Unit price is required for an added item");
|
||||||
|
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
||||||
|
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
||||||
|
|
||||||
|
var catalogItem = await repo.GetItemAsync(itemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||||
|
|
||||||
|
newLines.Add(new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoice.Id,
|
||||||
|
FK_ItemId = catalogItem.Id,
|
||||||
|
Item = catalogItem,
|
||||||
|
Quantity = edit.Quantity,
|
||||||
|
UnitPrice = edit.UnitPrice.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||||
|
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||||
|
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||||
|
await ApplyTotalsAsync(invoice, allLines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||||
|
{
|
||||||
|
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||||
|
if (quantity < 0 || quantity > ordered)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id)
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
@@ -50,12 +279,21 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||||
|
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
|
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Printed;
|
invoice.Status = InvoiceStatus.Printed;
|
||||||
invoice.PrintedAt = DateTime.Now;
|
invoice.PrintedAt = DateTime.Now;
|
||||||
invoice.PrintedBy = printedBy;
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
@@ -69,31 +307,37 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Printed)
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
var adjustments = request.Adjustments ?? [];
|
var adjustments = request.Adjustments ?? [];
|
||||||
if (adjustments.Count > 0)
|
|
||||||
|
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||||
|
foreach (var adjustment in adjustments)
|
||||||
{
|
{
|
||||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||||
decimal total = 0;
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||||
foreach (var item in poItems)
|
|
||||||
|
item.IsRemoved = adjustment.IsRemoved;
|
||||||
|
if (!adjustment.IsRemoved)
|
||||||
{
|
{
|
||||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
item.Quantity = adjustment.AdjustedQuantity;
|
||||||
|
|
||||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
|
||||||
|
|
||||||
total += billedQuantity * item.UnitPrice;
|
|
||||||
}
|
}
|
||||||
invoice.TotalAmount = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
await ApplyTotalsAsync(invoice, items);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Finalized;
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
invoice.FinalizedAt = DateTime.Now;
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
invoice.FinalizedBy = finalizedBy;
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
Task AddAsync(Models.Notification notification);
|
||||||
|
/// <summary>Stops tracking a notification (and its recipients) whose save failed.</summary>
|
||||||
|
void Discard(Models.Notification notification);
|
||||||
|
|
||||||
|
Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize);
|
||||||
|
Task<int> GetUnreadCountAsync(Guid userId);
|
||||||
|
Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId);
|
||||||
|
void RemoveRecipient(Models.NotificationRecipient recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds);
|
||||||
|
Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode);
|
||||||
|
Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id);
|
||||||
|
Task<Guid?> GetReportsToUserIdAsync(Guid userId);
|
||||||
|
Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id);
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take);
|
||||||
|
Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take);
|
||||||
|
void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients);
|
||||||
|
void RemoveNotifications(IEnumerable<Models.Notification> notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Daily retention purge: deletes read inbox rows older than <c>Notifications:RetentionDays</c>
|
||||||
|
/// (default 90), then any notification left with no recipients.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationCleanupJob(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<NotificationCleanupJob> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Let the app finish starting before the first run.
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await RunOnceAsync(stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var retentionDays = config.GetValue("Notifications:RetentionDays", 90);
|
||||||
|
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||||
|
|
||||||
|
using var scope = scopes.CreateScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||||
|
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||||
|
|
||||||
|
var removed = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize);
|
||||||
|
if (expired.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveRecipients(expired);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
removed += expired.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var orphans = await repo.GetOrphanNotificationsAsync(BatchSize);
|
||||||
|
if (orphans.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveNotifications(orphans);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0)
|
||||||
|
logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Every endpoint acts on the signed-in user's own notifications only.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetInbox(
|
||||||
|
[FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<IActionResult> GetUnreadCount()
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetUnreadCountAsync(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/read")]
|
||||||
|
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.MarkReadAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("read-all")]
|
||||||
|
public async Task<IActionResult> MarkAllRead(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
await service.MarkAllReadAsync(userId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.DeleteAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <param name="Values">Object whose public properties fill the {Token} placeholders in the type's templates.</param>
|
||||||
|
/// <param name="CreatedByUserId">The acting user; excluded from the recipients.</param>
|
||||||
|
public record NotificationArgs(
|
||||||
|
object? Values,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
/// <param name="Id">The caller's own recipient row id — use it for read / delete.</param>
|
||||||
|
public record NotificationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Type,
|
||||||
|
NotificationSeverity Severity,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool IsRead,
|
||||||
|
DateTime? ReadAt);
|
||||||
|
|
||||||
|
public record UnreadCountResponse(int Count);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server → client only. The server pushes a "notification" event to each recipient; clients never
|
||||||
|
/// invoke hub methods. Users are addressed by their id (see <see cref="NotificationUserIdProvider"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
public const string Path = "/hubs/notifications";
|
||||||
|
public const string ClientEvent = "notification";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a SignalR connection to the "UserId" claim that <c>AuthMiddleware</c> puts on the principal.</summary>
|
||||||
|
public class NotificationUserIdProvider : IUserIdProvider
|
||||||
|
{
|
||||||
|
public string? GetUserId(HubConnectionContext connection) =>
|
||||||
|
connection.User?.FindFirst("UserId")?.Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRecipientResolver(INotificationRepository repo)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Expands the rules into a de-duplicated list of active user ids, never including
|
||||||
|
/// <paramref name="actingUserId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Guid>> ResolveAsync(IEnumerable<RecipientRule> rules, Guid? actingUserId)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
ids.UnionWith(await ResolveRuleAsync(rule));
|
||||||
|
|
||||||
|
if (actingUserId is { } actor)
|
||||||
|
ids.Remove(actor);
|
||||||
|
|
||||||
|
if (ids.Count == 0) return [];
|
||||||
|
return await repo.FilterActiveUserIdsAsync(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Guid>> ResolveRuleAsync(RecipientRule rule)
|
||||||
|
{
|
||||||
|
switch (rule)
|
||||||
|
{
|
||||||
|
case UsersRule r:
|
||||||
|
return r.Ids;
|
||||||
|
|
||||||
|
case PermissionRule r:
|
||||||
|
return await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
|
||||||
|
case ScopeRule r:
|
||||||
|
return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
|
||||||
|
case PermissionInScopeRule r:
|
||||||
|
var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
return withPermission.Intersect(inScope);
|
||||||
|
|
||||||
|
case ReportsToRule r:
|
||||||
|
var supervisor = await repo.GetReportsToUserIdAsync(r.UserId);
|
||||||
|
return supervisor is { } s ? [s] : [];
|
||||||
|
|
||||||
|
case RouteOwnerRule r:
|
||||||
|
var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id);
|
||||||
|
return owner is { } o ? [o] : [];
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRepository(AppDBContext context) : INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Notification notification) =>
|
||||||
|
await context.Notifications.AddAsync(notification);
|
||||||
|
|
||||||
|
public void Discard(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
context.Entry(recipient).State = EntityState.Detached;
|
||||||
|
context.Entry(notification).State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Models.NotificationRecipient> InboxQuery(Guid userId, bool unreadOnly)
|
||||||
|
{
|
||||||
|
var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive);
|
||||||
|
return unreadOnly ? query.Where(r => !r.IsRead) : query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly) =>
|
||||||
|
InboxQuery(userId, unreadOnly).CountAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) =>
|
||||||
|
InboxQuery(userId, unreadOnly)
|
||||||
|
.Include(r => r.Notification)
|
||||||
|
.OrderByDescending(r => r.Notification.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<int> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).CountAsync();
|
||||||
|
|
||||||
|
public Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive);
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipient(Models.NotificationRecipient recipient) =>
|
||||||
|
context.NotificationRecipients.Remove(recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.ToList();
|
||||||
|
return context.Users
|
||||||
|
.Where(u => u.IsActive && ids.Contains(u.Id))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode) =>
|
||||||
|
context.Users
|
||||||
|
.Where(u => u.IsActive && context.RolePermissions.Any(rp =>
|
||||||
|
rp.FK_RoleId == u.FK_RoleId && rp.IsActive &&
|
||||||
|
rp.Permission.IsActive && rp.Permission.Code == permissionCode))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? regionId = null;
|
||||||
|
Guid? routeId = null;
|
||||||
|
Guid? storeId = null;
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ScopeKind.Region:
|
||||||
|
regionId = id;
|
||||||
|
break;
|
||||||
|
case ScopeKind.Route:
|
||||||
|
routeId = id;
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == id)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
case ScopeKind.Store:
|
||||||
|
storeId = id;
|
||||||
|
routeId = await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync();
|
||||||
|
if (routeId is { } rid)
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == rid)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = new HashSet<Guid>();
|
||||||
|
|
||||||
|
if (regionId is { } region)
|
||||||
|
userIds.UnionWith(await context.UserRegionScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RegionId == region)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (routeId is { } route)
|
||||||
|
userIds.UnionWith(await context.UserRouteScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RouteId == route)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (storeId is { } store)
|
||||||
|
userIds.UnionWith(await context.UserStoreScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_StoreId == store)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
return userIds.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Guid?> GetReportsToUserIdAsync(Guid userId) =>
|
||||||
|
context.Users.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.FK_ReportsToUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? routeId = kind switch
|
||||||
|
{
|
||||||
|
ScopeKind.Route => id,
|
||||||
|
ScopeKind.Store => await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (routeId is not { } rid) return null;
|
||||||
|
|
||||||
|
return await context.Routes.Where(r => r.Id == rid && r.IsActive)
|
||||||
|
.Select(r => r.FK_OwnerUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take) =>
|
||||||
|
context.Notifications
|
||||||
|
.Where(n => !n.Recipients.Any())
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients) =>
|
||||||
|
context.NotificationRecipients.RemoveRange(recipients);
|
||||||
|
|
||||||
|
public void RemoveNotifications(IEnumerable<Models.Notification> notifications) =>
|
||||||
|
context.Notifications.RemoveRange(notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
|
||||||
|
/// never throws, so a notification problem can never fail the business action that triggered it.
|
||||||
|
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
|
||||||
|
/// </summary>
|
||||||
|
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationService(
|
||||||
|
INotificationRepository repo,
|
||||||
|
NotificationRecipientResolver resolver,
|
||||||
|
IHubContext<NotificationHub> hub,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ILogger<NotificationService> logger) : INotificationService
|
||||||
|
{
|
||||||
|
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// ── Trigger side ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!NotificationTypes.All.TryGetValue(type, out var definition))
|
||||||
|
{
|
||||||
|
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
|
||||||
|
var values = ToDictionary(args.Values);
|
||||||
|
var notification = new Models.Notification
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Severity = definition.Severity,
|
||||||
|
Title = Truncate(Render(definition.TitleTemplate, values), 150),
|
||||||
|
Message = Truncate(Render(definition.MessageTemplate, values), 500),
|
||||||
|
EntityType = args.EntityType,
|
||||||
|
EntityId = args.EntityId,
|
||||||
|
Link = args.Link,
|
||||||
|
CreatedByUserId = args.CreatedByUserId,
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.AddAsync(notification);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Discard(notification);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushAsync(notification);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
|
||||||
|
type, args.EntityType, args.EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate from persistence: a failed push still leaves the notification in the inbox.
|
||||||
|
private async Task PushAsync(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await hub.Clients.User(recipient.FK_UserId.ToString())
|
||||||
|
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
|
||||||
|
notification.Id, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbox side ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
|
||||||
|
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
|
||||||
|
return new PagedResponse<NotificationResponse>(page, pageSize, total,
|
||||||
|
rows.Select(r => ToResponse(r.Notification, r)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
new(await repo.GetUnreadCountAsync(userId));
|
||||||
|
|
||||||
|
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
if (recipient.IsRead) return;
|
||||||
|
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = DateTime.Now;
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var unread = await repo.GetUnreadRecipientsAsync(userId);
|
||||||
|
if (unread.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
foreach (var recipient in unread)
|
||||||
|
{
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = now;
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
|
||||||
|
repo.RemoveRecipient(recipient);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
|
||||||
|
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
|
||||||
|
n.CreatedAt, r.IsRead, r.ReadAt);
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(object? values) =>
|
||||||
|
values is null
|
||||||
|
? []
|
||||||
|
: values.GetType().GetProperties()
|
||||||
|
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
|
||||||
|
|
||||||
|
private static string Render(string template, Dictionary<string, string> values) =>
|
||||||
|
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
|
||||||
|
|
||||||
|
private static string Truncate(string value, int max) =>
|
||||||
|
value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of every notification type. Services must pass one of these constants to
|
||||||
|
/// <see cref="INotificationService.NotifyAsync"/> — never a string literal. Templates use {Token}
|
||||||
|
/// placeholders filled from <see cref="NotificationArgs.Values"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotificationTypes
|
||||||
|
{
|
||||||
|
public const string RideCancelled = "RideCancelled";
|
||||||
|
public const string RideDriverAssigned = "RideDriverAssigned";
|
||||||
|
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||||
|
public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered";
|
||||||
|
public const string SupplierClaimApproved = "SupplierClaimApproved";
|
||||||
|
public const string SupplierClaimRejected = "SupplierClaimRejected";
|
||||||
|
public const string ShopPOConfirmed = "ShopPOConfirmed";
|
||||||
|
public const string ShopPOCancelled = "ShopPOCancelled";
|
||||||
|
public const string StockLow = "StockLow";
|
||||||
|
public const string CreditLimitBreached = "CreditLimitBreached";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All =
|
||||||
|
new Dictionary<string, NotificationTypeDefinition>
|
||||||
|
{
|
||||||
|
[RideCancelled] = new(NotificationSeverity.Critical,
|
||||||
|
"Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"),
|
||||||
|
[RideDriverAssigned] = new(NotificationSeverity.Info,
|
||||||
|
"Ride assigned", "You have been assigned to a ride."),
|
||||||
|
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||||
|
"Delivery failed", "Delivery to {StoreName} failed. {Note}"),
|
||||||
|
[DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning,
|
||||||
|
"Partial delivery", "Delivery to {StoreName} was only partly delivered."),
|
||||||
|
[SupplierClaimApproved] = new(NotificationSeverity.Success,
|
||||||
|
"Supplier claim approved", "Your supplier claim was approved."),
|
||||||
|
[SupplierClaimRejected] = new(NotificationSeverity.Warning,
|
||||||
|
"Supplier claim rejected", "Your supplier claim was rejected."),
|
||||||
|
[ShopPOConfirmed] = new(NotificationSeverity.Info,
|
||||||
|
"Shop order confirmed", "Shop order for {StoreName} was confirmed."),
|
||||||
|
[ShopPOCancelled] = new(NotificationSeverity.Warning,
|
||||||
|
"Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"),
|
||||||
|
[StockLow] = new(NotificationSeverity.Warning,
|
||||||
|
"Low stock", "{ItemName} is down to {Quantity} at {Location}."),
|
||||||
|
[CreditLimitBreached] = new(NotificationSeverity.Warning,
|
||||||
|
"Credit limit exceeded", "{StoreName} has exceeded its credit limit."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public enum ScopeKind { Region, Route, Store }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
|
||||||
|
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
|
||||||
|
/// and their stores, a route scope covers its stores.
|
||||||
|
/// </summary>
|
||||||
|
public abstract record RecipientRule
|
||||||
|
{
|
||||||
|
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
|
||||||
|
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose role holds the permission code.</summary>
|
||||||
|
public static RecipientRule Permission(string code) => new PermissionRule(code);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
|
||||||
|
|
||||||
|
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
|
||||||
|
new PermissionInScopeRule(code, kind, id);
|
||||||
|
|
||||||
|
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
|
||||||
|
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
|
||||||
|
|
||||||
|
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
|
||||||
|
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
|
||||||
|
public sealed record PermissionRule(string Code) : RecipientRule;
|
||||||
|
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
|
||||||
|
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||||
{
|
{
|
||||||
var lines =
|
var lines =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||||
select li;
|
select li;
|
||||||
|
|
||||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||||
{
|
{
|
||||||
var s = f.Search.Trim();
|
var s = f.Search.Trim();
|
||||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
|
||||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||||
var storePairs = await SoldLines(f)
|
var pairs = await SoldLines(f)
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||||
|
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||||
|
|
||||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||||
|
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||||
{
|
{
|
||||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return await SoldLines(f with { Search = null })
|
return await SoldLines(f with { Search = null })
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.GroupBy(l => l.FK_ItemId)
|
.GroupBy(l => l.FK_ItemId)
|
||||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.DeliveryRide;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,9 +12,14 @@ public class RideService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IDriverProfileRepository driverRepo,
|
IDriverProfileRepository driverRepo,
|
||||||
IDriverComplianceConfigRepository complianceRepo,
|
IDriverComplianceConfigRepository complianceRepo,
|
||||||
|
IDeliveryTaskRepository taskRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
|
ILogger<RideService> logger,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string RidesLink = "/operations/rides";
|
||||||
|
|
||||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||||
@@ -72,6 +79,12 @@ public class RideService(
|
|||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideDriverAssigned,
|
||||||
|
[RecipientRule.Users(driver.FK_UserId)],
|
||||||
|
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -125,9 +138,46 @@ public class RideService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||||
|
|
||||||
return ToResponse(ride);
|
return ToResponse(ride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||||
|
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||||
|
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||||
|
var routeIds = tasks
|
||||||
|
.Where(t => t.ShopPO?.Store is not null)
|
||||||
|
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var recipients = new List<RecipientRule>();
|
||||||
|
foreach (var routeId in routeIds)
|
||||||
|
{
|
||||||
|
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||||
|
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||||
|
}
|
||||||
|
if (recipients.Count == 0) return;
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideCancelled,
|
||||||
|
recipients,
|
||||||
|
new NotificationArgs(
|
||||||
|
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||||
|
"Ride", ride.Id, RidesLink, userId),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||||
{
|
{
|
||||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ 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<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ 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)
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ 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,9 +49,25 @@ 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);
|
||||||
|
|
||||||
|
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||||
|
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||||
|
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||||
|
|
||||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||||
await context.SchemeBundleComponents
|
await context.SchemeBundleComponents
|
||||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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;
|
||||||
@@ -14,6 +16,8 @@ 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);
|
||||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
|||||||
|
|
||||||
foreach (var scheme in candidates)
|
foreach (var scheme in candidates)
|
||||||
{
|
{
|
||||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||||
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));
|
||||||
@@ -275,6 +274,328 @@ 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)`
|
||||||
@@ -299,14 +620,18 @@ 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 > scheme.DateRangeTo)
|
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||||
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> items)
|
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||||
{
|
{
|
||||||
|
// 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:
|
||||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
|||||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||||
|
|
||||||
|
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||||
|
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||||
|
|
||||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||||
var eligibility = EvaluateRule(scheme, items);
|
var eligibility = EvaluateRule(scheme, items);
|
||||||
if (!eligibility.Eligible)
|
if (!eligibility.Eligible)
|
||||||
@@ -411,7 +739,10 @@ 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;
|
||||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
if (newItems.Count > 0)
|
||||||
|
{
|
||||||
|
await shopPORepo.AddItemsAsync(newItems);
|
||||||
|
|
||||||
|
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||||
|
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||||
|
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||||
|
{
|
||||||
|
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoiceId,
|
||||||
|
FK_ShopPOItemId = n.Id,
|
||||||
|
FK_ItemId = n.FK_ItemId,
|
||||||
|
Quantity = n.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = n.FK_SchemeId,
|
||||||
|
SchemeName = n.SchemeName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
await repo.AddRedemptionAsync(redemption);
|
await repo.AddRedemptionAsync(redemption);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -484,6 +835,8 @@ 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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("confirmed-without-invoice")]
|
||||||
|
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||||
|
[FromQuery] Guid? agentId,
|
||||||
|
[FromQuery] Guid? routeId,
|
||||||
|
[FromQuery] Guid? shopId)
|
||||||
|
{
|
||||||
|
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace Distribution.Features.ShopPO;
|
namespace Distribution.Features.ShopPO;
|
||||||
|
|
||||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||||
|
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||||
|
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record 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,
|
||||||
@@ -34,4 +39,5 @@ 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);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
|
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;
|
||||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
|||||||
IShopPORepository repo,
|
IShopPORepository repo,
|
||||||
IInvoiceRepository invoiceRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
SchemeService schemeService,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||||
|
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||||
|
{
|
||||||
|
if (line.IsFree)
|
||||||
|
return new ShopPOItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = line.SchemeId
|
||||||
|
};
|
||||||
|
|
||||||
|
var unitPrice = line.UnitPrice
|
||||||
|
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||||
|
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||||
|
|
||||||
|
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||||
|
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||||
|
notifications.NotifyAsync(
|
||||||
|
type,
|
||||||
|
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
|
||||||
|
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||||
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)).ToList(),
|
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||||
|
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,
|
||||||
@@ -27,7 +60,8 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -52,7 +86,14 @@ 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);
|
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||||
|
{
|
||||||
|
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||||
|
return items.Select(p => ToResponse(p, users)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||||
@@ -65,20 +106,14 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, request.StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
items.Add(item);
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
total += item.Quantity * item.UnitPrice;
|
||||||
|
|
||||||
items.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||||
|
|
||||||
var po = new Models.ShopPO
|
var po = new Models.ShopPO
|
||||||
{
|
{
|
||||||
FK_StoreId = request.StoreId,
|
FK_StoreId = request.StoreId,
|
||||||
@@ -93,6 +128,7 @@ 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; }
|
||||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
item.FK_ShopPOId = id;
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
newItems.Add(item);
|
||||||
|
total += item.Quantity * item.UnitPrice;
|
||||||
newItems.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ShopPOId = id,
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||||
|
|
||||||
po.TotalAmount = total;
|
po.TotalAmount = total;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +226,25 @@ 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
|
||||||
{
|
{
|
||||||
await invoiceRepo.AddAsync(invoice);
|
await invoiceRepo.AddAsync(invoice);
|
||||||
|
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await invoiceRepo.AddItemsAsync(lines);
|
||||||
|
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||||
po.InvoiceId = invoice.Id;
|
po.InvoiceId = invoice.Id;
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(id);
|
var saved = await repo.GetByIdAsync(id);
|
||||||
return await ToResponseWithUserAsync(saved!);
|
return await ToResponseWithUserAsync(saved!);
|
||||||
}
|
}
|
||||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Scheme;
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.StoreCredit;
|
using Distribution.Features.StoreCredit;
|
||||||
@@ -7,7 +8,7 @@ using Distribution.Models;
|
|||||||
|
|
||||||
namespace Distribution.Features.Store;
|
namespace Distribution.Features.Store;
|
||||||
|
|
||||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
|
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
private const int CreditApproverRoleLevel = 20;
|
private const int CreditApproverRoleLevel = 20;
|
||||||
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
var store = await repo.GetByIdAsync(id);
|
var store = await repo.GetByIdAsync(id);
|
||||||
if (store is null || !store.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(id)) return null;
|
if (!await HasStoreScopeAsync(id)) return null;
|
||||||
|
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||||
store.OutstandingBalance = request.NewBalance;
|
store.OutstandingBalance = request.NewBalance;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
||||||
|
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.CreditLimitBreached,
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
||||||
|
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
||||||
|
],
|
||||||
|
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(store);
|
return ToResponse(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
|||||||
ISupplierClaimRepository repo,
|
ISupplierClaimRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IGrnRepository grnRepo,
|
IGrnRepository grnRepo,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
return Map(claim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tells whoever raised the claim how it was decided (never the person who decided it).
|
||||||
|
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
|
||||||
|
notifications.NotifyAsync(
|
||||||
|
type,
|
||||||
|
[RecipientRule.Users(claim.RaisedByUserId)],
|
||||||
|
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
|
||||||
|
ct);
|
||||||
|
|
||||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var claim = await repo.GetByIdAsync(id)
|
var claim = await repo.GetByIdAsync(id)
|
||||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
return Map(claim);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Type = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Severity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Message = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EntityType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EntityId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Link = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NotificationRecipients",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_NotificationId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ReadAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NotificationRecipients_Notifications_FK_NotificationId",
|
||||||
|
column: x => x.FK_NotificationId,
|
||||||
|
principalTable: "Notifications",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NotificationRecipients_Users_FK_UserId",
|
||||||
|
column: x => x.FK_UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationRecipients_FK_NotificationId_FK_UserId",
|
||||||
|
table: "NotificationRecipients",
|
||||||
|
columns: new[] { "FK_NotificationId", "FK_UserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationRecipients_FK_UserId_IsRead",
|
||||||
|
table: "NotificationRecipients",
|
||||||
|
columns: new[] { "FK_UserId", "IsRead" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_EntityType_EntityId",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "EntityType", "EntityId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NotificationRecipients");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceItems : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_Invoices_FK_InvoiceId",
|
||||||
|
column: x => x.FK_InvoiceId,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_ShopPOItems_FK_ShopPOItemId",
|
||||||
|
column: x => x.FK_ShopPOItemId,
|
||||||
|
principalTable: "ShopPOItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_InvoiceId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_InvoiceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_ItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_ShopPOItemId");
|
||||||
|
|
||||||
|
// Backfill: snapshot each existing invoice's PO lines so older invoices can be edited/finalized.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceItems (Id, FK_InvoiceId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), inv.Id, poi.Id, poi.FK_ItemId, poi.Quantity, poi.UnitPrice, NOW(6), 1
|
||||||
|
FROM Invoices inv
|
||||||
|
JOIN ShopPOItems poi ON poi.FK_ShopPOId = inv.FK_ShopPOId AND poi.IsActive = 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceVersionsAndLineEdits : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)")
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsRemoved",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
VersionNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Stage = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersions_Invoices_FK_InvoiceId",
|
||||||
|
column: x => x.FK_InvoiceId,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceVersionItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceVersionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IsRemoved = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceVersionItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_InvoiceVersions_FK_InvoiceVersionId",
|
||||||
|
column: x => x.FK_InvoiceVersionId,
|
||||||
|
principalTable: "InvoiceVersions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_ShopPOItems_FK_ShopPOItemId",
|
||||||
|
column: x => x.FK_ShopPOItemId,
|
||||||
|
principalTable: "ShopPOItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_InvoiceVersionId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_InvoiceVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_ItemId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_ShopPOItemId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_ShopPOItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersions_FK_InvoiceId_VersionNumber",
|
||||||
|
table: "InvoiceVersions",
|
||||||
|
columns: new[] { "FK_InvoiceId", "VersionNumber" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// Backfill: one baseline version per existing invoice from its current lines.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceVersions (Id, FK_InvoiceId, VersionNumber, Stage, TotalAmount, CreatedBy, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), inv.Id, 1,
|
||||||
|
CASE inv.Status WHEN 0 THEN 0 WHEN 1 THEN 2 ELSE 3 END,
|
||||||
|
inv.TotalAmount, inv.CreatedBy, NOW(6), 1
|
||||||
|
FROM Invoices inv;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceVersionItems (Id, FK_InvoiceVersionId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, IsRemoved, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), v.Id, ii.FK_ShopPOItemId, ii.FK_ItemId, ii.Quantity, ii.UnitPrice, ii.IsRemoved, NOW(6), 1
|
||||||
|
FROM InvoiceVersions v
|
||||||
|
JOIN InvoiceItems ii ON ii.FK_InvoiceId = v.FK_InvoiceId AND ii.IsActive = 1
|
||||||
|
WHERE v.VersionNumber = 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceVersionItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsRemoved",
|
||||||
|
table: "InvoiceItems");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)",
|
||||||
|
oldNullable: true)
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceDiscount : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "DiscountAmount",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "decimal(65,30)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Invoices",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34500000-0000-0000-0000-000000000001"),
|
||||||
|
column: "DiscountAmount",
|
||||||
|
value: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Invoices",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34500000-0000-0000-0000-000000000002"),
|
||||||
|
column: "DiscountAmount",
|
||||||
|
value: 0m);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DiscountAmount",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ namespace Distribution.Models
|
|||||||
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
public enum InvoiceStatus { Draft, Printed, Finalized }
|
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||||
|
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||||
|
|
||||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ namespace Distribution.Models
|
|||||||
public DateTime? FinalizedAt { get; set; }
|
public DateTime? FinalizedAt { get; set; }
|
||||||
public Guid? FinalizedBy { get; set; }
|
public Guid? FinalizedBy { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scheme discount redeemed against the PO, subtracted from the billable lines to give TotalAmount.</summary>
|
||||||
|
public decimal DiscountAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Payable amount: billable lines minus DiscountAmount.</summary>
|
||||||
public decimal TotalAmount { get; set; }
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class InvoiceItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceId")]
|
||||||
|
public Invoice Invoice { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ShopPOItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ShopPOItemId")]
|
||||||
|
public ShopPOItem? ShopPOItem { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public bool IsRemoved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
|
||||||
|
public bool IsFree { get; set; }
|
||||||
|
public Guid? FK_SchemeId { get; set; }
|
||||||
|
public string? SchemeName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
// Immutable snapshot of an invoice's lines at a point in its lifecycle.
|
||||||
|
public class InvoiceVersion : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceId")]
|
||||||
|
public Invoice Invoice { get; set; }
|
||||||
|
|
||||||
|
public int VersionNumber { get; set; }
|
||||||
|
public InvoiceVersionStage Stage { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public Guid CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceVersionItem> Items { get; set; } = new List<InvoiceVersionItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class InvoiceVersionItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceVersionId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceVersionId")]
|
||||||
|
public InvoiceVersion InvoiceVersion { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ShopPOItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ShopPOItemId")]
|
||||||
|
public ShopPOItem? ShopPOItem { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public bool IsRemoved { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public enum NotificationSeverity { Info, Success, Warning, Critical }
|
||||||
|
|
||||||
|
/// <summary>One row per event. Who it was delivered to lives in <see cref="NotificationRecipient"/>.</summary>
|
||||||
|
public class Notification : Template
|
||||||
|
{
|
||||||
|
[MaxLength(60)]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public NotificationSeverity Severity { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(150)]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string Link { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid? CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
/// <summary>One row per user per notification, so read state is per user.</summary>
|
||||||
|
public class NotificationRecipient : Template
|
||||||
|
{
|
||||||
|
public Guid FK_NotificationId { get; set; }
|
||||||
|
[ForeignKey("FK_NotificationId")]
|
||||||
|
public Notification Notification { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_UserId { get; set; }
|
||||||
|
[ForeignKey("FK_UserId")]
|
||||||
|
public User User { get; set; }
|
||||||
|
|
||||||
|
public bool IsRead { get; set; }
|
||||||
|
public DateTime? ReadAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,11 @@ 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Distribution.Features.ReceivedCheque;
|
|||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
@@ -142,6 +143,15 @@ builder.Services.AddScoped<ReceivedChequeService>();
|
|||||||
builder.Services.AddScoped<SupplierPaymentService>();
|
builder.Services.AddScoped<SupplierPaymentService>();
|
||||||
builder.Services.AddScoped<SysConfigService>();
|
builder.Services.AddScoped<SysConfigService>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
builder.Services.AddScoped<NotificationRecipientResolver>();
|
||||||
|
builder.Services.AddScoped<NotificationService>();
|
||||||
|
builder.Services.AddScoped<INotificationService>(sp => sp.GetRequiredService<NotificationService>());
|
||||||
|
builder.Services.AddSingleton<Microsoft.AspNetCore.SignalR.IUserIdProvider, NotificationUserIdProvider>();
|
||||||
|
builder.Services.AddHostedService<NotificationCleanupJob>();
|
||||||
|
builder.Services.AddSignalR()
|
||||||
|
.AddJsonProtocol(options => options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
@@ -193,5 +203,6 @@ app.UseMiddleware<AuthMiddleware>();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapHub<NotificationHub>(NotificationHub.Path);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -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_OrderId (Guid?) — nullable (see FieldInvoiceId below)
|
├── FK_ShopPOId (Guid?) — nullable (see FieldInvoiceId below); the Shop PO the scheme was applied to
|
||||||
├── 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`/`Order` restrict-delete
|
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
|
||||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||||
|
|
||||||
### Item (extended)
|
### Item (extended)
|
||||||
```
|
```
|
||||||
@@ -124,8 +124,10 @@ 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 |
|
||||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
|
| — | `POST /api/schemes/suggest` | Body `{ storeId, items:[{itemId, quantity, unitPrice?}] }`; scheme suggestions for a Shop PO that is not saved yet |
|
||||||
|
| `evaluateSchemeEligibility(storeId, shopPOId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` (`orderId` is the Shop PO id) | Returns eligibility for every in-scope scheme, not just one |
|
||||||
| `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` |
|
||||||
|
|
||||||
@@ -216,13 +218,18 @@ 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/order only if: the scheme's `RegionId` is
|
2. `evaluateSchemeEligibility` matches a scheme to a store/Shop PO 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
|
||||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
|
||||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st).
|
||||||
|
3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||||
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)`).
|
||||||
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
|
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
|
||||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate.
|
||||||
|
4. `FreeBundle` eligibility: the Shop PO must contain every `SchemeBundleComponent` in at least its
|
||||||
|
listed `Qty` (paid lines only, same as rule 3); payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||||
|
`FreeQty` must be a whole number (validated on create / `PUT .../free-goods-rule`), because
|
||||||
|
Shop PO lines hold whole units.
|
||||||
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.
|
||||||
@@ -240,6 +247,80 @@ 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
|
||||||
|
|||||||
@@ -1,67 +1,330 @@
|
|||||||
# 21 — Notifications & Alerts
|
# 21 — Notifications & Alerts
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: v1 in-app built — backend, SignalR hub, frontend bell + view-all page, 10 unit tests. EF migration `AddNotifications` is generated but **not applied** to any database. Six of the seven Phase 1 triggers are wired into the services (see **Triggers**); `StockLow` is deferred. The temporary test endpoint and button were removed.
|
||||||
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
|
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
|
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
|
||||||
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
|
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
|
||||||
produce.
|
|
||||||
|
Real-time uses **SignalR**. A notification is **saved first, pushed second**, so offline users see
|
||||||
|
what they missed on their next load.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Implemented now — who gets which notification
|
||||||
|
|
||||||
No notification channel exists in this codebase today. This step is purely the delivery mechanism
|
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
|
||||||
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
|
|
||||||
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
|
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|
||||||
[12_store_management.md](./12_store_management.md), route deviation logic in
|
|---|---|---|---|---|---|---|---|
|
||||||
[09_fraud_controls.md](./09_fraud_controls.md)).
|
| 1 | Shop order **confirmed** | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` | **Shop order confirmed** — "Shop order for *Store* was confirmed." | The user who raised the order + that user's supervisor | The person who confirmed it | Info | `/sales/orders` |
|
||||||
|
| 2 | Shop order **cancelled** | `ShopPOService.CancelAsync` | **Shop order cancelled** — "Shop order for *Store* was cancelled: *reason*" | The user who raised the order + that user's supervisor | The person who cancelled it | Warning | `/sales/orders` |
|
||||||
|
| 3 | Supplier claim **approved** | `SupplierClaimService.ApproveAsync` | **Supplier claim approved** | The user who raised the claim | The approver | Success | `/returns-claims/supplier-claims` |
|
||||||
|
| 4 | Supplier claim **rejected** | `SupplierClaimService.RejectAsync` | **Supplier claim rejected** | The user who raised the claim | The person who rejected it | Warning | `/returns-claims/supplier-claims` |
|
||||||
|
| 5 | **Driver assigned** to a ride | `RideService.AssignDriverAsync` | **Ride assigned** — "You have been assigned to a ride." | The assigned driver | Everyone else | Info | `/operations/rides` |
|
||||||
|
| 6 | Ride **cancelled** | `RideService.CancelAsync` | **Ride cancelled** — "A ride was cancelled (*type*). *reason*" | The owner of every route in the ride, plus users with `delivery-task.manage` whose scope covers those routes | The person who cancelled it | Critical | `/operations/rides` |
|
||||||
|
| 7 | Delivery **failed** | `DeliveryTaskService.FailAsync` | **Delivery failed** — "Delivery to *Store* failed. *note*" | The owner of the store's route, plus users with `delivery-task.manage` whose scope covers the store | Nobody is excluded (this action does not record who did it) | Warning | `/operations/deliveries` |
|
||||||
|
| 8 | Delivery **partly delivered** | `DeliveryTaskService.ConfirmAsync` | **Partial delivery** — "Delivery to *Store* was only partly delivered." | Same as #7 | The person who confirmed it | Warning | `/operations/deliveries` |
|
||||||
|
| 9 | Store **credit limit crossed** | `StoreService.UpdateOutstandingBalanceAsync` | **Credit limit exceeded** — "*Store* has exceeded its credit limit." | The owner of the store's route, plus users with `store.manage` whose scope covers the store | The person who updated the balance. Fires only when the balance goes from within the limit to above it | Warning | `/stores/{id}` |
|
||||||
|
|
||||||
|
**Not wired yet:** low stock (`StockLow`), cheque returned / bounced / due, expiring batches, overdue payments, and the Phase 2 list below.
|
||||||
|
|
||||||
|
### Three rules that decide who sees what
|
||||||
|
|
||||||
|
1. **You are never notified of your own action.**
|
||||||
|
2. **Receiving needs no permission** — any logged-in user gets the bell and the page. Permissions only matter for *doing* the action, and for the "managers in scope" recipients (they need the permission **and** a scope covering the store or route).
|
||||||
|
3. **SuperAdmin usually acts, rarely receives.** It holds every permission but has no scope rows in the seed, so it is only a recipient when it created the record (orders, claims) or owns the route.
|
||||||
|
|
||||||
|
### With two logins — SuperAdmin and a sales rep
|
||||||
|
|
||||||
|
| Who does it | Action | Who gets the notification |
|
||||||
|
|---|---|---|
|
||||||
|
| Rep creates, **SuperAdmin** confirms or cancels | Shop order | **Rep** (creator), plus the rep's supervisor if they have one |
|
||||||
|
| **SuperAdmin** creates, rep confirms or cancels | Shop order | **SuperAdmin** (creator) |
|
||||||
|
| SuperAdmin | Sets a store's outstanding balance above its limit | The **rep**, if the rep owns that store's route. SuperAdmin never (actor) |
|
||||||
|
| SuperAdmin | Assigns a driver to a ride | The **rep**, only if the rep is that driver |
|
||||||
|
| SuperAdmin | Approves or rejects a supplier claim | Whoever raised the claim — the **rep** only if the rep raised it |
|
||||||
|
| Anyone | Fails a delivery / cancels a ride | The **rep**, if the rep owns a route involved. SuperAdmin only if it has a scope on that route |
|
||||||
|
|
||||||
|
Seed route owners: `nimal.perera` owns the Colombo and Negombo routes; `kamal.silva` owns Kandy and Matale.
|
||||||
|
|
||||||
|
### Quick test (two browsers, one login each, both on a dashboard page)
|
||||||
|
|
||||||
|
1. As the **rep**, create a shop order (Sales → New).
|
||||||
|
2. As **SuperAdmin**, confirm it, then create a second one and cancel it with a reason.
|
||||||
|
3. The rep sees a toast, a red badge on the bell and a new row on `/notifications` — "Shop order confirmed" and "Shop order cancelled: *reason*". Clicking one marks it read and opens `/sales/orders`.
|
||||||
|
4. Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
|
||||||
|
5. Credit limit: there is no screen for it, so use Swagger (`/swagger`): `PUT /api/stores/{id}/outstanding-balance` with `{ "newBalance": 60000 }` on a store whose limit is lower (Fort Mini Mart: limit 50,000). A second call with a higher value does not alert again; drop the balance back under the limit and set it over again to re-trigger.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Flow
|
||||||
|
|
||||||
### Notification
|
```
|
||||||
|
Service action ──► uow.CommitAsync() (business transaction, unchanged)
|
||||||
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
|
│
|
||||||
`ReadAt` (nullable).
|
▼ only after a successful commit
|
||||||
|
NotificationService.NotifyAsync(type, recipientRule, args, entity)
|
||||||
|
1. resolve recipients (users | permission | scope | reports-to)
|
||||||
|
2. insert Notification + NotificationRecipient rows
|
||||||
|
3. hub.Clients.Users(ids).SendAsync("notification", dto)
|
||||||
|
▼
|
||||||
|
Frontend provider: connection.on("notification") → badge + toast → click opens Link + marks read
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## Decisions
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
| Question | Decision |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) |
|
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
|
||||||
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
|
| Channels | In-app only (SignalR). Email / SMS / push deferred |
|
||||||
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) |
|
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
|
||||||
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
|
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
|
||||||
| — | `GET /api/notifications` — recipient's own notification inbox |
|
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
|
||||||
| — | `PUT /api/notifications/{id}/read` |
|
| Recipients | By **permission code** and scope, not role name. The seeded roles are `SalesRep`, `Supervisor`, `AreaManager`, `AreaSupervisor`, `RegionalManager`, `RegionalAgency`, `SuperAdmin`; there is no Accounts / operations role |
|
||||||
|
| Ids | `User`, `Permission` and entity ids are **Guid** (see seeds) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Data Model
|
||||||
|
|
||||||
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
|
**Notification** — one row per event
|
||||||
endpoints are inbox read/list.
|
|
||||||
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
|
| Field | Type | Notes |
|
||||||
transaction that triggered them — fire-and-forget, logged separately.
|
|---|---|---|
|
||||||
|
| `Id` | Guid PK | |
|
||||||
|
| `Type` | varchar(60) | `NotificationTypes` constant; frontend maps it to an icon |
|
||||||
|
| `Severity` | enum | `Info`, `Success`, `Warning`, `Critical` |
|
||||||
|
| `Title` / `Message` | varchar(150) / varchar(500) | Built from the type's templates |
|
||||||
|
| `EntityType` / `EntityId` | varchar(50) / Guid | The related record |
|
||||||
|
| `Link` | varchar(200) | Frontend route, e.g. `/payments/received-cheques/88` |
|
||||||
|
| `CreatedByUserId` | FK User, null | Null for system / scheduled events |
|
||||||
|
| `CreatedAt` | datetime | `DateTime.Now` |
|
||||||
|
| `DedupeKey` | — | **Not in v1.** Added with the Phase 1b scheduled jobs, e.g. `ItemBatchExpiring:77:2026-10-20` |
|
||||||
|
|
||||||
|
**NotificationRecipient** — one row per user per event (read state is per user)
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `Id` | Guid PK |
|
||||||
|
| `NotificationId` | FK |
|
||||||
|
| `UserId` | FK |
|
||||||
|
| `IsRead` / `ReadAt` | bool / datetime null |
|
||||||
|
|
||||||
|
Index `(UserId, IsRead)`; the bell orders by the joined `Notification.CreatedAt DESC`.
|
||||||
|
|
||||||
|
No config table in v1 — every type is enabled. (Later: `NotificationModuleConfig` with `Type` PK +
|
||||||
|
`InAppEnabled`, missing row = enabled.)
|
||||||
|
|
||||||
|
Example (failed delivery):
|
||||||
|
```
|
||||||
|
Notification: Type=DeliveryTaskFailed, Severity=Warning,
|
||||||
|
Title="Delivery failed", Message="Delivery to Fort Mini Mart failed.",
|
||||||
|
EntityType=DeliveryTask, EntityId=<guid>, Link=/operations/delivery-tasks/<guid>
|
||||||
|
NotificationRecipient: (that notification, Nimal Perera - route owner, unread)
|
||||||
|
(that notification, a delivery-task.manage user in Western region, unread)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipients
|
||||||
|
|
||||||
|
Every trigger names its rule. There is **no "notify everyone"** option.
|
||||||
|
|
||||||
|
| Rule | Resolves to |
|
||||||
|
|---|---|
|
||||||
|
| `Users(ids)` | Those users (creator, driver, approver, claim owner) |
|
||||||
|
| `Permission(code)` | Active users whose role has that permission code (`User.FK_RoleId` → `RolePermission` → `Permission.Code`), e.g. `delivery-task.manage` |
|
||||||
|
| `Scope(kind, id)` | Users whose `UserStoreScope` / `UserRegionScope` / `UserRouteScope` covers the record |
|
||||||
|
| `PermissionInScope(code, kind, id)` | Intersection of the two (e.g. `store.manage` users for that store's region) |
|
||||||
|
| `ReportsTo(userId)` | The user's supervisor via `User.FK_ReportsToUserId` |
|
||||||
|
| `RouteOwner(kind, id)` | The owner (`Route.FK_OwnerUserId`) of the route, or of the store's route when `kind` is `Store` |
|
||||||
|
|
||||||
|
`SuperAdmin` holds every permission, so it receives `Permission` rules automatically; use
|
||||||
|
`PermissionInScope` where that would be too noisy.
|
||||||
|
|
||||||
|
The resolver always drops inactive users and the acting user, and de-duplicates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
Each is called **after** the owning service's `uow.CommitAsync()`.
|
||||||
|
|
||||||
|
**Phase 1 — must have**
|
||||||
|
|
||||||
|
| Type | Raised by | When | Recipients | Severity |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Type | Status | Raised by | When | Recipients | Severity |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `SupplierClaimApproved` / `Rejected` | ✅ wired | `SupplierClaimService.ApproveAsync` / `RejectAsync` | Decision | `Users(RaisedByUserId)` (not the decider) | Success / Warning |
|
||||||
|
| `RideCancelled` | ✅ wired | `RideService.CancelAsync` | Cancelled (message carries `CancelRequestType` + reason) | For each distinct route in the ride's delivery tasks: `RouteOwner(Route)` + `PermissionInScope(delivery-task.manage, Route)`. A ride has no route of its own, so routes come from its tasks' stores | Critical |
|
||||||
|
| `RideDriverAssigned` | ✅ wired | `RideService.AssignDriverAsync` | Driver assigned | `Users(driver.FK_UserId)` | Info |
|
||||||
|
| `DeliveryTaskFailed` | ✅ wired | `DeliveryTaskService.FailAsync` | Task failed | `RouteOwner(Store)` + `PermissionInScope(delivery-task.manage, Store)` | Warning |
|
||||||
|
| `DeliveryTaskPartiallyDelivered` | ✅ wired | `DeliveryTaskService.ConfirmAsync` | Confirmed with `fullyDelivered == false` (a separate type: "failed" would be the wrong wording) | same as above | Warning |
|
||||||
|
| `ShopPOConfirmed` / `Cancelled` | ✅ wired | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` / `CancelAsync` | Status change | `Users(RaisedById)` + `ReportsTo(RaisedById)` (not the actor) | Info / Warning |
|
||||||
|
| `CreditLimitBreached` | ✅ wired | `StoreService.UpdateOutstandingBalanceAsync` | Balance crosses from ≤ to > `CreditLimit` (crossing only). This is the only place `OutstandingBalance` is ever written — invoices and payments do not update it | `RouteOwner(Store)` + `PermissionInScope(store.manage, Store)` | Warning |
|
||||||
|
| `StockLow` | ⏸ deferred | Stock movement | Crosses to ≤ `Item.ReorderLevel` | Undecided — warehouses have no region / scope link, so `PermissionInScope` cannot target them. Needs a per-request tracker + action filter because stock moves from many call sites that commit separately | Warning |
|
||||||
|
|
||||||
|
**Waiting on finance permissions** (not in the seed yet — add a cheque permission first)
|
||||||
|
|
||||||
|
`ReceivedChequeReturned` (`ReceivedChequeService.UpdateStatusAsync`, action `Return`, Critical) ·
|
||||||
|
`ChequePageBounced` (`ChequeBookService.UpdatePageStatusAsync`, action `Bounce`, Critical)
|
||||||
|
|
||||||
|
**Phase 1b — scheduled, second pass** (daily `BackgroundService`, uses `DedupeKey`)
|
||||||
|
|
||||||
|
`ReceivedChequeDueSoon` (≤ 2 days) · `ItemBatchExpiring` (≤ N days) · `PaymentOverdue` ·
|
||||||
|
`DriverComplianceExpiring`
|
||||||
|
|
||||||
|
**Phase 2 — later**
|
||||||
|
|
||||||
|
`PoCancelled` / `PoClosed` · `GrnShortOrDamaged` · `LoadingSheetConfirmed` · `InvoiceFinalized` ·
|
||||||
|
`ReturnCreated` / `ClaimSettled` · `PurchaseReturnCreated` · `ApprovalPending` ·
|
||||||
|
`RouteDeviationFlagged` · `RoleAssigned` / `ScopeChanged` · `SyncFailed`
|
||||||
|
|
||||||
|
Excluded on purpose: plain master-data CRUD (brand, category, UOM, vehicle, region).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/notifications?unreadOnly=&page=&pageSize=` | Caller's inbox, newest first |
|
||||||
|
| `GET /api/notifications/unread-count` | Badge count |
|
||||||
|
| `PATCH /api/notifications/{id}/read` | Mark one read (own row only) |
|
||||||
|
| `PATCH /api/notifications/read-all` | Mark all read |
|
||||||
|
| `DELETE /api/notifications/{id}` | Remove caller's recipient row |
|
||||||
|
| `WS /hubs/notifications` | SignalR; server → client event `notification` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Features/Notifications/ (namespace Distribution.Features.Notifications — "Notification" alone clashes with Models.Notification)
|
||||||
|
NotificationController.cs thin, no UoW
|
||||||
|
NotificationService.cs resolve → persist → push; owns uow.CommitAsync()
|
||||||
|
INotificationRepository.cs / NotificationRepository.cs
|
||||||
|
NotificationRecipientResolver.cs Users / Permission / Scope / PermissionInScope / ReportsTo
|
||||||
|
NotificationTypes.cs constants + definition (severity, title/message template)
|
||||||
|
NotificationDTOs.cs
|
||||||
|
NotificationHub.cs SignalR hub
|
||||||
|
NotificationCleanupJob.cs daily retention purge
|
||||||
|
NotificationScheduleJob.cs Phase 1b alerts (second pass)
|
||||||
|
Models/Notification.cs, NotificationRecipient.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code reference
|
||||||
|
|
||||||
|
**Registry** — services never pass string literals:
|
||||||
|
```csharp
|
||||||
|
public static class NotificationTypes
|
||||||
|
{
|
||||||
|
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||||
|
// ...
|
||||||
|
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
|
||||||
|
{
|
||||||
|
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||||
|
"Delivery failed", "Delivery to {StoreName} failed."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service contract** — never throws; failures are logged:
|
||||||
|
```csharp
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
// recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated
|
||||||
|
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
// RecipientRule: Users(ids) | Permission(code) | Scope(kind, id) | PermissionInScope(code, kind, id) | ReportsTo(userId) | RouteOwner(kind, id)
|
||||||
|
// NotificationArgs: Values (template tokens), EntityType, EntityId (Guid), Link, CreatedByUserId (Guid?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Calling it from an existing service:**
|
||||||
|
```csharp
|
||||||
|
await _uow.CommitAsync(ct);
|
||||||
|
await _notifications.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||||
|
[RecipientRule.Users(route.FK_OwnerUserId),
|
||||||
|
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Region, route.FK_RegionId)],
|
||||||
|
new(new { StoreName = store.StoreName }, "DeliveryTask", task.Id,
|
||||||
|
$"/operations/delivery-tasks/{task.Id}", currentUserId), ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Program.cs:**
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
builder.Services.AddHostedService<NotificationCleanupJob>();
|
||||||
|
// CORS: AllowCredentials() with explicit origins
|
||||||
|
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||||
|
```
|
||||||
|
The hub authenticates through the existing `access-token` cookie (read by `AuthMiddleware`); the
|
||||||
|
client connects with `withCredentials: true`. A `?access_token=` query fallback is not needed unless
|
||||||
|
the token ever moves out of the cookie. Users are addressed by their `UserId` claim
|
||||||
|
(`NotificationUserIdProvider`). Each recipient gets its own push because the DTO `Id` is the
|
||||||
|
recipient row id.
|
||||||
|
|
||||||
|
**Frontend** (`contexts/notification-context.tsx`):
|
||||||
|
```tsx
|
||||||
|
const connection = new HubConnectionBuilder()
|
||||||
|
.withUrl(`${API}/hubs/notifications`, { withCredentials: true })
|
||||||
|
.withAutomaticReconnect().build();
|
||||||
|
|
||||||
|
connection.on("notification", (n: NotificationDto) => {
|
||||||
|
setUnread((c) => c + 1);
|
||||||
|
setItems((p) => [n, ...p]);
|
||||||
|
toast(n.title, { description: n.message, action: { label: "Open", onClick: () => router.push(n.link) } });
|
||||||
|
});
|
||||||
|
connection.onreconnected(refetch); // catch up on anything missed
|
||||||
|
```
|
||||||
|
Also: `lib/api/notifications.ts`, bell + popover in the dashboard header, `/notifications` "view all" page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Notify **after** a successful commit, inside try/catch + log. A notification failure must never
|
||||||
|
fail or roll back the business action.
|
||||||
|
- Saving and the hub push are separate try/catch blocks (a failed push still leaves the inbox entry).
|
||||||
|
- Unknown type: throws in dev/test, logged and skipped in production. A unit test checks that every
|
||||||
|
constant has a definition.
|
||||||
|
- No per-type config in v1: every type is always delivered.
|
||||||
|
- Click-through is mandatory: `EntityType`, `EntityId` and `Link` are required; clicking marks read.
|
||||||
|
- Retention: `NotificationCleanupJob` deletes read recipients older than 90 days (SysConfig), then
|
||||||
|
events with no recipients left.
|
||||||
|
- No Email/SMS switches in the UI until a sender exists.
|
||||||
|
- `AGENTS.md`: `uow.CommitAsync()` only in services, never `SaveChangesAsync()`, `DateTime.Now` only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
|
Resolved — see **Decisions**. Remaining for later:
|
||||||
significantly changes the infrastructure needed (third-party provider integration, see
|
|
||||||
[22_integration_api.md](./22_integration_api.md)).
|
1. Add a cheque / finance permission to the seed, then enable the two cheque triggers.
|
||||||
|
2. Email / SMS / push: see [22_integration_api.md](./22_integration_api.md).
|
||||||
|
3. If the API ever runs on more than one instance, add a SignalR Redis backplane.
|
||||||
|
4. Per-type mute (`NotificationModuleConfig` + admin screen) when requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Build order
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
1. Entities (`Notification`, `NotificationRecipient`) + EF migration.
|
||||||
|
2. `Features/Notifications/` (repository, resolver, service, controller, DTOs, `NotificationTypes`) + retention job.
|
||||||
|
3. SignalR hub, `Program.cs` registration, user-id provider.
|
||||||
|
4. Frontend: API client, provider, bell, toast, click-through, view-all page.
|
||||||
|
|
||||||
|
5. Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not,
|
||||||
|
because `Distribution.Tests` does not currently compile). `StockLow` deferred.
|
||||||
|
6. Later: `StockLow`, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.
|
||||||
|
|
||||||
|
Remaining before this works end to end: apply the `AddNotifications` migration.
|
||||||
|
|||||||
@@ -82,6 +82,91 @@ Invoice
|
|||||||
├── IsActive, CreatedAt — from Template
|
├── IsActive, CreatedAt — from Template
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Invoice Line Items & Version History
|
||||||
|
|
||||||
|
> **Implemented.** Class/column names follow the code: the PO is `ShopPO`/`ShopPOItem`, and the
|
||||||
|
> invoice FK is `FK_ShopPOId` (not `FK_OrderId` as sketched above).
|
||||||
|
|
||||||
|
The invoice no longer bills straight from the PO. It owns its own lines, and every change to them is
|
||||||
|
snapshotted, so the original, printed and finalized quantities can all be compared.
|
||||||
|
|
||||||
|
```
|
||||||
|
InvoiceItem — the live billing lines
|
||||||
|
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
|
||||||
|
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
||||||
|
├── UnitPrice (decimal)
|
||||||
|
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
|
||||||
|
└── IsActive, CreatedAt — from Template
|
||||||
|
|
||||||
|
InvoiceVersion — immutable snapshot of the invoice
|
||||||
|
├── FK_InvoiceId, VersionNumber — unique per invoice, starts at 1
|
||||||
|
├── Stage (InvoiceVersionStage) — Created, DraftEdited, Printed, Finalized
|
||||||
|
├── TotalAmount, CreatedBy
|
||||||
|
└── Items → InvoiceVersionItem — FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Add / remove lines (Draft):** `PUT /api/invoices/{id}/items` takes edits keyed by `invoiceItemId`
|
||||||
|
(change quantity/price, or `isRemoved: true` to strike the line — it is kept, excluded from the
|
||||||
|
total, shown struck through on the invoice view and print copy in every status, and can be
|
||||||
|
restored while Draft) or by `itemId` (add a catalog item
|
||||||
|
that was not on the PO; `unitPrice` required, quantity > 0, no duplicate of a live line). Added
|
||||||
|
lines have no `ShopPOItem` (`FK_ShopPOItemId` is nullable → no `orderedQuantity`, no max). Versions
|
||||||
|
store `IsRemoved` per line. At `Finalize`, adjustments are keyed by `invoiceItemId` and can also
|
||||||
|
set `isRemoved`. When the loading sheet is linked to invoices, it must count added lines.
|
||||||
|
- **Original PO quantity** stays on `ShopPOItem.Quantity` (locked once the PO is confirmed) and is
|
||||||
|
returned as `orderedQuantity` on each invoice line.
|
||||||
|
- `InvoiceItem` rows are copied from the PO's active lines at invoice creation (Mode A and Mode B).
|
||||||
|
- A version is written, in the same transaction as the change, when the invoice is:
|
||||||
|
|
||||||
|
| Stage | Written when |
|
||||||
|
|---|---|
|
||||||
|
| `Created` (v1) | Invoice created |
|
||||||
|
| `DraftEdited` | `PUT /api/invoices/{id}/items` while `Draft` |
|
||||||
|
| `Printed` | `POST /api/invoices/{id}/print` — the quantities on the paper copy |
|
||||||
|
| `Finalized` | `POST /api/invoices/{id}/finalize` — after the agent's edits are applied |
|
||||||
|
|
||||||
|
- Comparing `Printed` vs `Finalized` shows what the agent changed on paper.
|
||||||
|
- Migrations `AddInvoiceItems` and `AddInvoiceVersions` backfill lines and one baseline version for
|
||||||
|
invoices that already existed (stage mapped from the invoice's status at migration time, so they
|
||||||
|
have no earlier history).
|
||||||
|
|
||||||
|
### Invoice Screens & Printing (frontend)
|
||||||
|
|
||||||
|
> **Implemented** in `distribution_fe`.
|
||||||
|
|
||||||
|
**Invoice document** — one shared component (`components/invoice/invoice-template.tsx`) renders the
|
||||||
|
invoice for the preview, the draft editor and the print tab:
|
||||||
|
|
||||||
|
- **A4**: `INVOICE` header with Invoice No. (`INV-xxxxxxxx`), date and PO ref; a Status row shown
|
||||||
|
until the invoice is Finalized; *Bill To* (store name, plus address/phone from the PO); item table
|
||||||
|
(Item · Qty · Unit Price · Amount); totals (Subtotal, Total, and Paid / Balance Due when payments
|
||||||
|
exist); payments list; notes lines for the agent's edits on non-finalized copies; signature lines.
|
||||||
|
Soft slate rules, no heavy colour.
|
||||||
|
- **80mm / 58mm receipts**: single black-and-white column with dashed rules (`*** DRAFT ***` /
|
||||||
|
`*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!").
|
||||||
|
- Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`.
|
||||||
|
- Deleted lines are shown **struck through in every status** and excluded from all totals.
|
||||||
|
- Scheme discount: shown as Subtotal / Scheme discount / Total (`Invoice.DiscountAmount`), re-derived from the current lines on every edit, print and finalize (see 17_promotions_schemes.md). Free-issue lines get a **Free Issue** column and come after the paid lines.
|
||||||
|
- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date.
|
||||||
|
|
||||||
|
**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
|
||||||
|
URL/date header and footer are not printed; the A4 sheet pads itself with 14mm), and opens the
|
||||||
|
print dialog automatically. *Print* on a Draft asks who printed it, calls `POST /print`, then opens
|
||||||
|
this tab (the tab is opened inside the click so popup blockers allow it). Printed/Finalized
|
||||||
|
invoices have *Reprint*, which opens the tab without changing status.
|
||||||
|
|
||||||
|
**Draft editor** — pencil icon (Draft only, also *Edit Items* on the invoice page) opens a full page, `/sales/invoices/{id}/edit` (not a modal), showing the invoice template in edit mode: quantity/price
|
||||||
|
inputs, a trash icon per line (undo icon restores a deleted line), and an add-item row aligned to
|
||||||
|
the table columns (item dropdown excluding items already on the invoice, quantity, price
|
||||||
|
auto-filled from the store's price list, Enter or **+** to add). Save is disabled until something
|
||||||
|
changes, a summary shows what is pending (the browser warns before leaving with unsaved edits), and an item picked but not yet added is included on save. Non-Draft invoices redirect back to the invoice page.
|
||||||
|
Each save writes a `DraftEdited` version.
|
||||||
|
|
||||||
|
**Configuration** — *Invoice Creation* card: two toggles, **Auto** (Mode A) and **Manual** (Mode B,
|
||||||
|
default), mutually exclusive (turning one on turns the other off; the active one is locked on), each
|
||||||
|
with a one-line explanation and a process line (`Confirm PO → … → Edit draft → Print → Finalize`).
|
||||||
|
|
||||||
### New Enum — `InvoiceStatus`
|
### New Enum — `InvoiceStatus`
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
@@ -140,6 +225,7 @@ Draft → Printed → Finalized
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
|
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
|
||||||
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
|
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
|
||||||
|
| Draft (edit) | `PUT /api/invoices/{id}/items` | Office user edits the draft's lines (quantity `0..ordered`, price `>= 0`). Status stays `Draft`; `TotalAmount` recomputed; a `DraftEdited` version is saved. |
|
||||||
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
|
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
|
||||||
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
|
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
|
||||||
|
|
||||||
@@ -162,7 +248,10 @@ Draft → Printed → Finalized
|
|||||||
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
|
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
|
||||||
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
|
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
|
||||||
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
|
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
|
||||||
reflects the current line items.
|
reflects the current invoice lines (`InvoiceItem`), not the PO lines.
|
||||||
|
8a. Invoice lines are editable only while `Draft`. At `Finalize`, only quantities can be adjusted
|
||||||
|
(never above the ordered quantity). Every change writes an `InvoiceVersion` snapshot — see
|
||||||
|
[Invoice Line Items & Version History](#invoice-line-items--version-history).
|
||||||
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
||||||
separate axis from `Invoice.Status` — not merged in this pass.
|
separate axis from `Invoice.Status` — not merged in this pass.
|
||||||
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
|
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
|
||||||
@@ -201,9 +290,11 @@ Draft → Printed → Finalized
|
|||||||
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
||||||
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
||||||
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`** — quantity adjustments, returns, damage, and
|
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
||||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled
|
||||||
|
by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see
|
||||||
|
[17_promotions_schemes.md](../17_promotions_schemes.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -35,10 +35,28 @@ status transition and stock movement along the way.
|
|||||||
|
|
||||||
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
||||||
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
||||||
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
|
The frontend calls it when **Configuration → Invoice Creation** is set to **Auto** (Mode A, the
|
||||||
|
confirm dialog and PO edit page read the mode); with **Manual** (Mode B, the default) it calls the
|
||||||
|
plain `Confirm` and the invoice is created later from `/sales/orders` → **Create Invoice**.
|
||||||
- Lifecycle: `Draft → Printed → Finalized`.
|
- Lifecycle: `Draft → Printed → Finalized`.
|
||||||
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
|
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
|
||||||
quantity) and recomputes `TotalAmount` from the adjusted lines.
|
created (either mode). The PO's original quantity stays on `ShopPOItem` and is shown as
|
||||||
|
`orderedQuantity`.
|
||||||
|
- While `Draft`, lines can be edited (`PUT /invoices/{id}/items`): change quantity (`0..ordered`
|
||||||
|
on PO lines) and price (`>= 0`), **delete** a line (it is kept, struck through, excluded from
|
||||||
|
the total, and can be restored), or **add** a catalog item that was not on the PO (no ordered
|
||||||
|
quantity / max). `TotalAmount` is recomputed from the non-deleted lines.
|
||||||
|
- Finalizing applies the agent's edits from the printed copy to the lines (quantity capped
|
||||||
|
between `0` and the ordered quantity, or a line removed) and recomputes `TotalAmount`.
|
||||||
|
- Every change is snapshotted as an `InvoiceVersion` — see
|
||||||
|
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-line-items--version-history).
|
||||||
|
- Frontend, `/sales/orders` row icons: **eye** = invoice preview, **pencil** (Draft) = open the edit page for the
|
||||||
|
draft invoice (`/sales/invoices/[id]/edit`: trash/undo icon per line, add-item row), **printer** (Draft) =
|
||||||
|
mark as printed and open the print tab; on Printed/Finalized the printer reprints without
|
||||||
|
changing status; plus finalize and record payment. `/sales/invoices/[id]` shows the items table
|
||||||
|
(ordered vs billed, Added/Removed badges), the version history, and the same actions.
|
||||||
|
- Invoice document and print tab: see
|
||||||
|
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-screens--printing-frontend).
|
||||||
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
||||||
`Pending → PartiallyPaid → Paid`.
|
`Pending → PartiallyPaid → Paid`.
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||||
|
import { RegionSalesPie } from './region-sales-pie'
|
||||||
|
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
from: string // yyyy-MM-dd
|
from: string // yyyy-MM-dd
|
||||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
|||||||
function LoadingGrid() {
|
function LoadingGrid() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3" aria-busy="true">
|
<div className="flex flex-col gap-3" aria-busy="true">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
const { current: c, previous: p } = sales.kpis
|
const { current: c, previous: p } = sales.kpis
|
||||||
|
const { current: cash, previous: cashPrev } = sales.cash
|
||||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
<Kpi
|
<Kpi
|
||||||
title={title}
|
title={title}
|
||||||
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||||
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
|
||||||
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{kpi('Net Sales', 'netSales')}
|
{kpi('Net Sales', 'netSales')}
|
||||||
{kpi('Gross Sales', 'grossSales')}
|
{kpi('Gross Sales', 'grossSales')}
|
||||||
{kpi('Total Orders', 'orders', false)}
|
{kpi('Total Orders', 'orders', false)}
|
||||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{kpi('Sales Returns', 'returnValue')}
|
{kpi('Sales Returns', 'returnValue')}
|
||||||
|
{kpi('Discounts', 'discounts')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<Kpi
|
||||||
|
title="Collected"
|
||||||
|
value={fmtMoney(cash.collected)}
|
||||||
|
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Outstanding"
|
||||||
|
value={fmtMoney(cash.outstanding)}
|
||||||
|
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Overdue"
|
||||||
|
value={fmtMoney(cash.overdue)}
|
||||||
|
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Collection Rate"
|
||||||
|
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
|
||||||
|
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
|
||||||
|
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
|
||||||
|
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
|
||||||
|
Gross profit uses item cost prices and covers only items that have one. Sales targets are not tracked per order yet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
<SalesTrendInteractive className="md:col-span-2" />
|
||||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
|
||||||
</ChartCard>
|
|
||||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Sales by Region">
|
<RegionSalesPie rows={sales.byRegion} />
|
||||||
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||||
<MultiAreaChart
|
<MultiAreaChart
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
import type { StockMovementResponse } from '@/types/api'
|
||||||
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||||
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
|
||||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
|
|
||||||
const DEFAULT_ACTIVE = [
|
const DEFAULT_ACTIVE = [
|
||||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
|||||||
confirmedSheetsCount: number
|
confirmedSheetsCount: number
|
||||||
pendingTasksCount: number
|
pendingTasksCount: number
|
||||||
partialTasksCount: number
|
partialTasksCount: number
|
||||||
lowStock: LowStockItemResponse[]
|
|
||||||
movements: StockMovementResponse[]
|
movements: StockMovementResponse[]
|
||||||
warehouseBalances: WarehouseBalanceRow[]
|
warehouseBalances: WarehouseBalanceRow[]
|
||||||
outstandingAmount: number
|
outstandingAmount: number
|
||||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
|||||||
openRides: [],
|
openRides: [],
|
||||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||||
pendingTasksCount: 0, partialTasksCount: 0,
|
pendingTasksCount: 0, partialTasksCount: 0,
|
||||||
lowStock: [], movements: [], warehouseBalances: [],
|
movements: [], warehouseBalances: [],
|
||||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||||
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeIds.has('lowStock')) {
|
|
||||||
fetches.push(
|
|
||||||
stock.lowStock()
|
|
||||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
|
||||||
.catch(() => {})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeIds.has('stockMovements')) {
|
if (activeIds.has('stockMovements')) {
|
||||||
fetches.push(
|
fetches.push(
|
||||||
stock.movements({ pageSize: 5 })
|
stock.movements({ pageSize: 5 })
|
||||||
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'lowStock':
|
|
||||||
return data.lowStock.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
|
||||||
</div>
|
|
||||||
{data.lowStock.map(row => (
|
|
||||||
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
|
||||||
<span className="text-foreground">{row.itemName}</span>
|
|
||||||
<span className="text-muted-foreground">{row.locationName}</span>
|
|
||||||
<span className="font-semibold text-destructive">{row.quantity}</span>
|
|
||||||
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case 'stockMovements':
|
case 'stockMovements':
|
||||||
return data.movements.length === 0 ? (
|
return data.movements.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Label, Pie, PieChart, Sector } from 'recharts'
|
||||||
|
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
|
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||||
|
const MAX_SLICES = 5
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
|
||||||
|
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
|
||||||
|
function topWithOther(rows: Array<{ name: string; amount: number }>) {
|
||||||
|
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
|
||||||
|
if (sorted.length <= MAX_SLICES) return sorted
|
||||||
|
const head = sorted.slice(0, MAX_SLICES - 1)
|
||||||
|
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
|
||||||
|
* out and show its amount and share in the centre.
|
||||||
|
*/
|
||||||
|
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
|
||||||
|
const slices = useMemo(
|
||||||
|
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const config = useMemo(
|
||||||
|
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
|
||||||
|
[slices],
|
||||||
|
)
|
||||||
|
const total = slices.reduce((t, s) => t + s.amount, 0)
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
|
||||||
|
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
|
||||||
|
const activeIndex = slices.findIndex(s => s.key === activeKey)
|
||||||
|
const active = slices[activeIndex]
|
||||||
|
|
||||||
|
const renderShape = useCallback(
|
||||||
|
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
|
||||||
|
index === activeIndex ? (
|
||||||
|
<g>
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
<Sector {...props} outerRadius={outerRadius} />
|
||||||
|
),
|
||||||
|
[activeIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sales by Region</CardTitle>
|
||||||
|
<CardDescription>Share of gross sales</CardDescription>
|
||||||
|
{active && (
|
||||||
|
<CardAction>
|
||||||
|
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
|
||||||
|
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
|
||||||
|
<SelectValue placeholder="Select region" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
<SelectGroup>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<SelectItem key={s.key} value={s.key} label={s.name}>
|
||||||
|
<span className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 justify-center">
|
||||||
|
{!active ? (
|
||||||
|
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
|
||||||
|
</div>
|
||||||
|
)} />}
|
||||||
|
/>
|
||||||
|
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||||
|
const cy = viewBox.cy ?? 0
|
||||||
|
return (
|
||||||
|
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||||
|
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
|
||||||
|
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
|
||||||
|
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { dashboardReports, getApiError } from '@/lib/api/index'
|
||||||
|
import type { DashboardSalesSummary } from '@/lib/api/reports'
|
||||||
|
|
||||||
|
const MAX_DAYS = 90
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ value: '90', label: 'Last 3 months' },
|
||||||
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: '7', label: 'Last 7 days' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
||||||
|
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
|
||||||
|
interface Loaded {
|
||||||
|
end: string // yyyy-MM-dd of the last day covered
|
||||||
|
trend: DashboardSalesSummary['trend']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
|
||||||
|
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
|
||||||
|
*/
|
||||||
|
export function SalesTrendInteractive({ className }: { className?: string }) {
|
||||||
|
const [range, setRange] = useState('90')
|
||||||
|
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const today = new Date()
|
||||||
|
dashboardReports
|
||||||
|
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
|
||||||
|
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return
|
||||||
|
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
|
||||||
|
setLoaded(null)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const days = Number(range)
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!loaded) return []
|
||||||
|
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
|
||||||
|
const end = parseIso(loaded.end)
|
||||||
|
return Array.from({ length: days }, (_, i) => {
|
||||||
|
const date = toIso(addDays(end, i - (days - 1)))
|
||||||
|
const point = byDay.get(date)
|
||||||
|
return { date, sales: point?.sales ?? 0 }
|
||||||
|
})
|
||||||
|
}, [loaded, days])
|
||||||
|
|
||||||
|
const total = data.reduce((sum, d) => sum + d.sales, 0)
|
||||||
|
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sales Trend</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Select value={range} onValueChange={v => v && setRange(v)}>
|
||||||
|
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
|
||||||
|
<SelectValue placeholder="Last 3 months" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loaded === undefined ? (
|
||||||
|
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||||
|
) : loaded === null || data.every(d => d.sales === 0) ? (
|
||||||
|
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
|
||||||
|
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
|
||||||
|
/>
|
||||||
|
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import {
|
||||||
|
invoiceCreationMode as invoiceCreationModeApi,
|
||||||
|
DEFAULT_INVOICE_CREATION_MODE,
|
||||||
|
getApiError,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||||
|
|
||||||
|
// What each toggle stands for. Both end the same way: edit the draft, print, finalize.
|
||||||
|
const POSITIONS: { mode: InvoiceCreationMode; label: string; meaning: string; steps: string[] }[] = [
|
||||||
|
{ mode: 'ModeA', label: 'Auto', meaning: 'Confirming a PO also creates its draft invoice.', steps: ['Confirm PO', 'Draft created'] },
|
||||||
|
{ mode: 'ModeB', label: 'Manual', meaning: 'Confirm first, then create the invoice yourself.', steps: ['Confirm PO', 'Create invoice'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function InvoiceCreationModeCard() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoiceCreationModeApi.get()
|
||||||
|
.then(setMode)
|
||||||
|
.catch(err => toast.add({ title: 'Failed to load invoice creation mode', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function save(next: InvoiceCreationMode) {
|
||||||
|
if (next === mode) return
|
||||||
|
const previous = mode
|
||||||
|
setMode(next)
|
||||||
|
setPending(true)
|
||||||
|
try {
|
||||||
|
await invoiceCreationModeApi.set(next)
|
||||||
|
} catch (err) {
|
||||||
|
setMode(previous)
|
||||||
|
toast.add({ title: 'Failed to update invoice creation mode', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
|
||||||
|
<CardHeader className="border-b border-border/60 pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<HugeiconsIcon icon={ReceiptDollarIcon} size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Invoice Creation</CardTitle>
|
||||||
|
<CardDescription className="text-xs">When invoices are created for a Shop PO</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{POSITIONS.map((p) => {
|
||||||
|
const active = p.mode === mode
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.mode}
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
|
||||||
|
active ? 'border-primary/40 bg-muted' : 'border-border/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label htmlFor={`invoice-creation-${p.mode}`} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">{p.label}</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">{p.meaning}</span>
|
||||||
|
</label>
|
||||||
|
{/* One mode is always on: turning one on turns the other off; the active one cannot be switched off. */}
|
||||||
|
<Switch
|
||||||
|
id={`invoice-creation-${p.mode}`}
|
||||||
|
checked={active}
|
||||||
|
disabled={pending || active}
|
||||||
|
onCheckedChange={(v) => v && save(p.mode)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[...(POSITIONS.find((p) => p.mode === mode)?.steps ?? []), 'Edit draft', 'Print', 'Finalize'].join(' → ')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||||
import { GrnTaxCard } from './_components/grn-tax-card'
|
import { GrnTaxCard } from './_components/grn-tax-card'
|
||||||
|
import { InvoiceCreationModeCard } from './_components/invoice-creation-mode-card'
|
||||||
|
|
||||||
export default function ConfigurationPage() {
|
export default function ConfigurationPage() {
|
||||||
return (
|
return (
|
||||||
@@ -11,7 +12,6 @@ export default function ConfigurationPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
|
|
||||||
<div className="grid grid-cols-12 items-stretch gap-6">
|
<div className="grid grid-cols-12 items-stretch gap-6">
|
||||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
<ProductCapabilitiesCard />
|
<ProductCapabilitiesCard />
|
||||||
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
|
|||||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
<GrnTaxCard />
|
<GrnTaxCard />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
|
<InvoiceCreationModeCard />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
|||||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||||
|
import { NotificationBell } from '@/components/layout/notification-bell'
|
||||||
|
import { NotificationProvider } from '@/contexts/notification-context'
|
||||||
import { TopLoader } from '@/components/shared/top-loader'
|
import { TopLoader } from '@/components/shared/top-loader'
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -10,21 +12,24 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<NotificationProvider>
|
||||||
<TopLoader />
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<TopLoader />
|
||||||
<SidebarInset>
|
<AppSidebar />
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
<SidebarInset>
|
||||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
<div className="ml-auto">
|
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||||
<ThemeToggle />
|
<div className="ml-auto flex items-center gap-1">
|
||||||
</div>
|
<NotificationBell />
|
||||||
</header>
|
<ThemeToggle />
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
</div>
|
||||||
{children}
|
</header>
|
||||||
</main>
|
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||||
</SidebarInset>
|
{children}
|
||||||
<BottomNav />
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarInset>
|
||||||
|
<BottomNav />
|
||||||
|
</SidebarProvider>
|
||||||
|
</NotificationProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state'
|
||||||
|
import { NotificationRow } from '@/components/layout/notification-bell'
|
||||||
|
import { useNotifications } from '@/contexts/notification-context'
|
||||||
|
import { getApiError } from '@/lib/api'
|
||||||
|
import { notifications } from '@/lib/api/notifications'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { NotificationResponse } from '@/types/notifications'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type Filter = 'all' | 'unread'
|
||||||
|
|
||||||
|
export function NotificationsClient() {
|
||||||
|
const { recent, unreadCount, open, markAllRead, refresh } = useNotifications()
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
|
const [items, setItems] = useState<NotificationResponse[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// The caller decides whether to show the loading state (it is true on first render).
|
||||||
|
const load = useCallback(async (targetPage: number, reset: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await notifications.list({
|
||||||
|
unreadOnly: filter === 'unread',
|
||||||
|
page: targetPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setItems(prev => (reset ? result.items : [...prev, ...result.items]))
|
||||||
|
setPage(targetPage)
|
||||||
|
setTotal(result.total)
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: 'Could not load notifications', description: getApiError(error), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
// Reload when the filter changes or a new notification is pushed in.
|
||||||
|
const latestId = recent[0]?.id
|
||||||
|
useEffect(() => { void load(1, true) }, [load, latestId])
|
||||||
|
|
||||||
|
async function handleOpen(n: NotificationResponse) {
|
||||||
|
setItems(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
||||||
|
await open(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
await markAllRead()
|
||||||
|
await load(1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(n: NotificationResponse) {
|
||||||
|
try {
|
||||||
|
await notifications.remove(n.id)
|
||||||
|
setItems(prev => prev.filter(x => x.id !== n.id))
|
||||||
|
setTotal(t => Math.max(0, t - 1))
|
||||||
|
void refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: 'Could not delete notification', description: getApiError(error), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = items.length < total
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Notifications</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex rounded-2xl border border-border p-0.5">
|
||||||
|
{(['all', 'unread'] as const).map(f => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
variant={filter === f ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setLoading(true); setFilter(f) }}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'All' : 'Unread'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={unreadCount === 0} onClick={() => { void handleMarkAllRead() }}>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border">
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<EmptyState
|
||||||
|
title={filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||||
|
description="Alerts about deliveries, stock, claims and orders will show up here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{items.map(n => (
|
||||||
|
<div key={n.id} className="group relative">
|
||||||
|
<NotificationRow notification={n} onOpen={item => { void handleOpen(item) }} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Delete notification"
|
||||||
|
className="absolute top-2 right-2 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
onClick={() => { void handleDelete(n) }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Button variant="outline" disabled={loading} onClick={() => { setLoading(true); void load(page + 1, false) }} className="self-center">
|
||||||
|
{loading ? 'Loading…' : 'Load more'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationsClient } from './_components/notifications-client'
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return <NotificationsClient />
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'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} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'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 />
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useFieldArray, useWatch } from 'react-hook-form'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import {
|
||||||
|
schemes as schemesApi,
|
||||||
|
regions as regionsApi,
|
||||||
|
items as itemsApi,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
|
||||||
|
import { RULE_META, parseRuleConfig } from './scheme-labels'
|
||||||
|
|
||||||
|
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||||
|
regionId: z.string().optional(),
|
||||||
|
eligibleSegment: z.string().optional(),
|
||||||
|
dateRangeFrom: z.string().min(1, 'Required'),
|
||||||
|
dateRangeTo: z.string().min(1, 'Required'),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
// Buy & get free
|
||||||
|
triggerItemId: z.string(),
|
||||||
|
triggerQty: z.number(),
|
||||||
|
// Buy & get free + combo gift: what the store receives
|
||||||
|
freeItemId: z.string(),
|
||||||
|
freeQty: z.number(),
|
||||||
|
// Combo gift: the products that must be bought together
|
||||||
|
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
|
||||||
|
// Flat discount
|
||||||
|
discountPercent: z.number(),
|
||||||
|
// Spend more, save more
|
||||||
|
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
|
||||||
|
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
|
||||||
|
const pct = (n: number) => n > 0 && n <= 100
|
||||||
|
|
||||||
|
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
|
||||||
|
fail(['dateRangeTo'], 'End date must not be before the start date')
|
||||||
|
|
||||||
|
if (v.ruleType === 'BuyXGetY') {
|
||||||
|
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
|
||||||
|
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
|
||||||
|
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
|
||||||
|
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'FreeBundle') {
|
||||||
|
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
|
||||||
|
v.bundle.forEach((b, i) => {
|
||||||
|
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
|
||||||
|
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
|
||||||
|
})
|
||||||
|
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
|
||||||
|
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
|
||||||
|
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
|
||||||
|
if (v.ruleType === 'SlabDiscount') {
|
||||||
|
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
|
||||||
|
v.tiers.forEach((t, i) => {
|
||||||
|
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
|
||||||
|
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
const EMPTY: FormValues = {
|
||||||
|
name: '',
|
||||||
|
ruleType: 'BuyXGetY',
|
||||||
|
regionId: '',
|
||||||
|
eligibleSegment: '',
|
||||||
|
dateRangeFrom: '',
|
||||||
|
dateRangeTo: '',
|
||||||
|
isActive: true,
|
||||||
|
triggerItemId: '',
|
||||||
|
triggerQty: 10,
|
||||||
|
freeItemId: '',
|
||||||
|
freeQty: 1,
|
||||||
|
bundle: [{ itemId: '', qty: 1 }],
|
||||||
|
discountPercent: 10,
|
||||||
|
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(scheme: SchemeResponse): FormValues {
|
||||||
|
const cfg = parseRuleConfig(scheme)
|
||||||
|
return {
|
||||||
|
...EMPTY,
|
||||||
|
name: scheme.name,
|
||||||
|
ruleType: scheme.ruleType,
|
||||||
|
regionId: scheme.regionId ?? '',
|
||||||
|
eligibleSegment: scheme.eligibleSegment ?? '',
|
||||||
|
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||||
|
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||||
|
isActive: scheme.isActive,
|
||||||
|
triggerItemId: scheme.triggerItemId ?? '',
|
||||||
|
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
|
||||||
|
freeItemId: scheme.freeItemId ?? '',
|
||||||
|
freeQty: scheme.freeQty ?? EMPTY.freeQty,
|
||||||
|
bundle: scheme.bundleComponents.length
|
||||||
|
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
|
||||||
|
: EMPTY.bundle,
|
||||||
|
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
|
||||||
|
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemeFormProps {
|
||||||
|
/** null = create a new scheme, otherwise edit this one. */
|
||||||
|
scheme: SchemeResponse | null
|
||||||
|
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/pricing/schemes')
|
||||||
|
const isEdit = !!scheme
|
||||||
|
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||||
|
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
|
||||||
|
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
|
||||||
|
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
|
||||||
|
const v = useWatch({ control: form.control }) as FormValues
|
||||||
|
const ruleType = v.ruleType ?? 'BuyXGetY'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(scheme ? toFormValues(scheme) : EMPTY)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
|
||||||
|
.then(([regions, items]) => {
|
||||||
|
setRegionsList(getPaginatedItems(regions))
|
||||||
|
setItemsList(getPaginatedItems(items))
|
||||||
|
})
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
|
||||||
|
|
||||||
|
// Live sentence so the user can check the offer reads the way they intended.
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const n = (x?: number) => (x && x > 0 ? x : '?')
|
||||||
|
if (ruleType === 'BuyXGetY') {
|
||||||
|
const buy = itemName(v.triggerItemId) ?? 'a product'
|
||||||
|
const free = itemName(v.freeItemId) ?? 'a product'
|
||||||
|
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
|
||||||
|
}
|
||||||
|
if (ruleType === 'FreeBundle') {
|
||||||
|
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
|
||||||
|
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
|
||||||
|
}
|
||||||
|
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
|
||||||
|
const levels = [...(v.tiers ?? [])]
|
||||||
|
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||||
|
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
|
||||||
|
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [v, ruleType, itemsList])
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
|
||||||
|
const ruleConfig =
|
||||||
|
values.ruleType === 'FlatPercent'
|
||||||
|
? JSON.stringify({ discountPercent: values.discountPercent })
|
||||||
|
: values.ruleType === 'SlabDiscount'
|
||||||
|
? JSON.stringify({ tiers: values.tiers })
|
||||||
|
: null
|
||||||
|
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
|
||||||
|
const n = (id: string) => itemName(id) ?? 'item'
|
||||||
|
const autoName =
|
||||||
|
values.ruleType === 'BuyXGetY'
|
||||||
|
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||||
|
: values.ruleType === 'FreeBundle'
|
||||||
|
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||||
|
: values.ruleType === 'FlatPercent'
|
||||||
|
? `${values.discountPercent}% off the order`
|
||||||
|
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
|
||||||
|
const common = {
|
||||||
|
name: values.name.trim() || autoName,
|
||||||
|
ruleConfig,
|
||||||
|
regionId: values.regionId || null,
|
||||||
|
eligibleSegment: values.eligibleSegment?.trim() || null,
|
||||||
|
dateRangeFrom: values.dateRangeFrom,
|
||||||
|
dateRangeTo: values.dateRangeTo,
|
||||||
|
isActive: values.isActive,
|
||||||
|
}
|
||||||
|
const goods = isGoods
|
||||||
|
? {
|
||||||
|
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
|
||||||
|
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
|
||||||
|
freeItemId: values.freeItemId,
|
||||||
|
freeQty: values.freeQty,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!scheme) {
|
||||||
|
await schemesApi.create({
|
||||||
|
...common,
|
||||||
|
ruleType: values.ruleType,
|
||||||
|
...goods,
|
||||||
|
bundleComponents:
|
||||||
|
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Scheme created', type: 'success' })
|
||||||
|
} else {
|
||||||
|
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
|
||||||
|
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
|
||||||
|
if (isGoods) {
|
||||||
|
await schemesApi.setFreeGoodsRule(scheme.id, {
|
||||||
|
triggerItemId: goods.triggerItemId,
|
||||||
|
triggerQty: goods.triggerQty,
|
||||||
|
freeItemId: values.freeItemId,
|
||||||
|
freeQty: values.freeQty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (values.ruleType === 'FreeBundle') {
|
||||||
|
const keep = new Set(values.bundle.map((b) => b.itemId))
|
||||||
|
for (const old of scheme.bundleComponents) {
|
||||||
|
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
|
||||||
|
}
|
||||||
|
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
|
||||||
|
}
|
||||||
|
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||||
|
}
|
||||||
|
goBack()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
|
||||||
|
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
|
||||||
|
<SelectTrigger aria-invalid={invalid}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{itemsList.map((i) => (
|
||||||
|
<SelectItem key={i.id} value={i.id}>
|
||||||
|
{i.name} — {i.sku}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={Number.isFinite(value) ? value : ''}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
|
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{readOnly
|
||||||
|
? 'How this offer is set up.'
|
||||||
|
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{readOnly && scheme && (
|
||||||
|
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
|
||||||
|
Edit Scheme
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
|
||||||
|
<FieldGroup>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Scheme name"
|
||||||
|
description="Optional. If left blank, the name is created from the offer."
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{RULE_ORDER.map((type) => {
|
||||||
|
const selected = ruleType === type
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
disabled={isEdit && !selected}
|
||||||
|
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
|
||||||
|
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
|
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
|
||||||
|
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can't be changed after it is created.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Buy & get free ─────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'BuyXGetY' && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">The store buys</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="triggerItemId"
|
||||||
|
label="Product"
|
||||||
|
render={({ field, invalid }) =>
|
||||||
|
itemSelect(field.value as string, (id) => {
|
||||||
|
field.onChange(id)
|
||||||
|
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
|
||||||
|
}, 'Select product…', invalid)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="triggerQty"
|
||||||
|
label="Quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">…and gets free</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeItemId"
|
||||||
|
label="Free product"
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeQty"
|
||||||
|
label="Free quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Combo gift ─────────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'FreeBundle' && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Add product
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{bundle.fields.map((row, index) => (
|
||||||
|
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`bundle.${index}.itemId`}
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`bundle.${index}.qty`}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bundle.fields.length === 1}
|
||||||
|
onClick={() => bundle.remove(index)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{form.formState.errors.bundle?.message && (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-foreground">…and gets this free</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeItemId"
|
||||||
|
label="Free product"
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeQty"
|
||||||
|
label="Free quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Flat discount ──────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'FlatPercent' && (
|
||||||
|
<div className="rounded-xl border p-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="discountPercent"
|
||||||
|
label="Discount on the whole order (%)"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Spend more, save more ──────────────────────────────────── */}
|
||||||
|
{ruleType === 'SlabDiscount' && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-foreground">Discount levels</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Add level
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{tiers.fields.map((row, index) => (
|
||||||
|
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`tiers.${index}.minOrderAmount`}
|
||||||
|
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`tiers.${index}.discountPercent`}
|
||||||
|
label={index === 0 ? 'Discount (%)' : undefined}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={index === 0 ? 'mt-6' : ''}
|
||||||
|
disabled={tiers.fields.length === 1}
|
||||||
|
onClick={() => tiers.remove(index)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{form.formState.errors.tiers?.message && (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
|
||||||
|
<span className="font-medium text-foreground">Summary: </span>
|
||||||
|
<span className="text-muted-foreground">{preview}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateRangeFrom"
|
||||||
|
label="Starts on"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||||
|
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateRangeTo"
|
||||||
|
label="Ends on"
|
||||||
|
description="The offer still applies on this day."
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||||
|
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="regionId"
|
||||||
|
label="Region"
|
||||||
|
description="Leave on All Regions to offer it everywhere."
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Regions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All Regions</SelectItem>
|
||||||
|
{regionsList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="eligibleSegment"
|
||||||
|
label="Store segment"
|
||||||
|
description="Optional, e.g. Gold. Empty = every store."
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={(field.value as string) ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="All segments"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{!readOnly && <div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'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,6 +1,7 @@
|
|||||||
'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'
|
||||||
|
|
||||||
@@ -12,8 +13,7 @@ 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 { SchemeSheet } from './scheme-sheet'
|
import { RULE_META, describeScheme } from './scheme-labels'
|
||||||
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,14 +26,11 @@ 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)
|
||||||
@@ -52,18 +49,15 @@ export function SchemesClient() {
|
|||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
router.push('/pricing/schemes/new')
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(scheme: SchemeResponse) {
|
function openEdit(scheme: SchemeResponse) {
|
||||||
setEditing(scheme)
|
router.push(`/pricing/schemes/${scheme.id}/edit`)
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openView(scheme: SchemeResponse) {
|
function openView(scheme: SchemeResponse) {
|
||||||
setViewing(scheme)
|
router.push(`/pricing/schemes/${scheme.id}`)
|
||||||
setViewOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(scheme: SchemeResponse) {
|
async function handleDelete(scheme: SchemeResponse) {
|
||||||
@@ -91,11 +85,14 @@ export function SchemesClient() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ruleType',
|
key: 'ruleType',
|
||||||
title: 'Rule Type',
|
title: 'Offer',
|
||||||
fields: ['ruleType'],
|
fields: ['ruleType'],
|
||||||
width: 160,
|
minWidth: 280,
|
||||||
cellRender: (row) => (
|
cellRender: (row) => (
|
||||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
|
<div className="flex flex-col gap-1">
|
||||||
|
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -177,18 +174,6 @@ export function SchemesClient() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SchemeSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
scheme={editing}
|
|
||||||
onSuccess={fetchData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SchemeViewSheet
|
|
||||||
open={viewOpen}
|
|
||||||
onClose={() => setViewOpen(false)}
|
|
||||||
scheme={viewing}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SchemeForm } from '../_components/scheme-form'
|
||||||
|
|
||||||
|
export default function NewSchemePage() {
|
||||||
|
return <SchemeForm scheme={null} />
|
||||||
|
}
|
||||||
@@ -24,10 +24,14 @@ import {
|
|||||||
items as itemsApi,
|
items as itemsApi,
|
||||||
priceLists as priceListsApi,
|
priceLists as priceListsApi,
|
||||||
users as usersApi,
|
users as usersApi,
|
||||||
|
invoiceCreationMode as invoiceCreationModeApi,
|
||||||
|
DEFAULT_INVOICE_CREATION_MODE,
|
||||||
getApiError,
|
getApiError,
|
||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
|
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'),
|
||||||
@@ -56,6 +60,13 @@ export default function EditShopPOPage() {
|
|||||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
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 [freeLines, setFreeLines] = useState<FreeLine[]>([])
|
||||||
|
const [discountIds, setDiscountIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -83,9 +94,21 @@ export default function EditShopPOPage() {
|
|||||||
setUsersList(users)
|
setUsersList(users)
|
||||||
form.reset({
|
form.reset({
|
||||||
items: poRes.items
|
items: poRes.items
|
||||||
.filter((i) => i.isActive)
|
.filter((i) => i.isActive && !i.isFree)
|
||||||
.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' })
|
||||||
@@ -98,9 +121,14 @@ export default function EditShopPOPage() {
|
|||||||
if (!po || !confirmedByUserId) return
|
if (!po || !confirmedByUserId) return
|
||||||
setConfirming(true)
|
setConfirming(true)
|
||||||
try {
|
try {
|
||||||
const updated = await shopPosApi.confirm(po.id, confirmedByUserId)
|
const updated = mode === 'ModeA'
|
||||||
|
? await shopPosApi.confirmAndCreateInvoice(po.id, confirmedByUserId)
|
||||||
|
: await shopPosApi.confirm(po.id, confirmedByUserId)
|
||||||
setPo(updated)
|
setPo(updated)
|
||||||
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
toast.add({
|
||||||
|
title: mode === 'ModeA' ? 'Shop PO confirmed and invoice created' : 'Shop PO confirmed',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -125,7 +153,13 @@ export default function EditShopPOPage() {
|
|||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
try {
|
try {
|
||||||
await shopPosApi.updateItems(id, { items: values.items })
|
await shopPosApi.updateItems(id, {
|
||||||
|
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) {
|
||||||
@@ -283,6 +317,20 @@ 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)
|
const activeItems = po.items.filter((i) => i.isActive).sort((a, b) => Number(a.isFree) - Number(b.isFree))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -155,6 +155,7 @@ 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>
|
||||||
@@ -163,15 +164,21 @@ export default function ShopPOViewPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{activeItems.length === 0 ? (
|
{activeItems.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
|
<td colSpan={6} 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">{item.itemName}</td>
|
<td className="px-3 py-2">
|
||||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
{item.itemName}
|
||||||
|
{item.isFree && item.schemeName && (
|
||||||
|
<Badge variant="outline" className="ml-2">{item.schemeName}</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.isFree ? '-' : item.quantity}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.isFree ? item.quantity : '-'}</td>
|
||||||
<td className="px-3 py-2 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>
|
||||||
@@ -181,8 +188,14 @@ 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={4} className="px-3 py-3 text-right text-sm font-medium text-foreground">
|
<td colSpan={5} 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">
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { shopPos as shopPosApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import {
|
||||||
|
shopPos as shopPosApi,
|
||||||
|
users as usersApi,
|
||||||
|
invoiceCreationMode as invoiceCreationModeApi,
|
||||||
|
DEFAULT_INVOICE_CREATION_MODE,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||||
import type { ShopPOResponse, UserResponse } from '@/types/api'
|
import type { ShopPOResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -39,6 +47,7 @@ interface ConfirmShopPODialogProps {
|
|||||||
|
|
||||||
export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) {
|
export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) {
|
||||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
|
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -51,14 +60,20 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
|||||||
usersApi.list()
|
usersApi.list()
|
||||||
.then((res) => setUsersList(res))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
|
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
if (!po) return null
|
if (!po) return null
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
try {
|
try {
|
||||||
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
|
if (mode === 'ModeA') {
|
||||||
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
await shopPosApi.confirmAndCreateInvoice(po!.id, values.confirmedByUserId)
|
||||||
|
toast.add({ title: 'Shop PO confirmed and invoice created', type: 'success' })
|
||||||
|
} else {
|
||||||
|
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
|
||||||
|
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
||||||
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -71,7 +86,10 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Confirm Shop PO</DialogTitle>
|
<DialogTitle>Confirm Shop PO</DialogTitle>
|
||||||
<DialogDescription>{po.storeName} — Rs. {po.totalAmount.toLocaleString()}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{po.storeName} — Rs. {po.totalAmount.toLocaleString()}
|
||||||
|
{mode === 'ModeA' && ' · a draft invoice will be created'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { schemes as schemesApi } from '@/lib/api/index'
|
||||||
|
import type { SchemeSuggestion } from '@/types/api'
|
||||||
|
|
||||||
|
export interface FreeLine {
|
||||||
|
itemId: string
|
||||||
|
itemName: string
|
||||||
|
quantity: number
|
||||||
|
schemeId: string
|
||||||
|
schemeName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaidLine {
|
||||||
|
itemId: string
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
storeId: string
|
||||||
|
paidLines: PaidLine[]
|
||||||
|
freeLines: FreeLine[]
|
||||||
|
onChange: (lines: FreeLine[]) => void
|
||||||
|
/** Accepted percentage-discount scheme ids (at most one). */
|
||||||
|
discountIds: string[]
|
||||||
|
onDiscountChange: (ids: string[]) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFreeGoods = (s: SchemeSuggestion) => s.ruleType === 'BuyXGetY' || s.ruleType === 'FreeBundle'
|
||||||
|
const isDiscount = (s: SchemeSuggestion) => s.ruleType === 'FlatPercent' || s.ruleType === 'SlabDiscount'
|
||||||
|
const money = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggests free-issue schemes for the paid lines being entered. Nothing is added automatically: the user
|
||||||
|
* accepts a suggestion, after which the free line stays editable. If a paid-quantity change shrinks the
|
||||||
|
* entitlement, accepted free quantities are trimmed to match (and the user is told).
|
||||||
|
*/
|
||||||
|
export function FreeIssuePanel({ storeId, paidLines, freeLines, onChange, discountIds, onDiscountChange, disabled }: Props) {
|
||||||
|
const [suggestions, setSuggestions] = useState<SchemeSuggestion[]>([])
|
||||||
|
const freeRef = useRef(freeLines)
|
||||||
|
freeRef.current = freeLines
|
||||||
|
const discountRef = useRef(discountIds)
|
||||||
|
discountRef.current = discountIds
|
||||||
|
|
||||||
|
const paidKey = JSON.stringify(
|
||||||
|
paidLines.filter((l) => l.itemId && l.quantity > 0).map((l) => [l.itemId, l.quantity, l.unitPrice])
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storeId) {
|
||||||
|
setSuggestions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = paidLines.filter((l) => l.itemId && l.quantity > 0)
|
||||||
|
if (lines.length === 0) {
|
||||||
|
setSuggestions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
schemesApi
|
||||||
|
.suggest({
|
||||||
|
storeId,
|
||||||
|
items: lines.map((l) => ({
|
||||||
|
itemId: l.itemId,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.unitPrice > 0 ? l.unitPrice : null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSuggestions(result)
|
||||||
|
reconcile(result)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Suggestions are advisory; the PO can still be saved without them.
|
||||||
|
if (!cancelled) setSuggestions([])
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [storeId, paidKey])
|
||||||
|
|
||||||
|
// Trim or drop accepted free lines that the current paid lines no longer entitle.
|
||||||
|
function reconcile(result: SchemeSuggestion[]) {
|
||||||
|
const current = freeRef.current
|
||||||
|
let changed = false
|
||||||
|
const next: FreeLine[] = []
|
||||||
|
for (const line of current) {
|
||||||
|
const s = result.find((r) => r.schemeId === line.schemeId)
|
||||||
|
const entitled = s?.eligible ? Math.floor(s.freeQty ?? 0) : 0
|
||||||
|
if (entitled <= 0) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const used = next.filter((n) => n.schemeId === line.schemeId).reduce((sum, n) => sum + n.quantity, 0)
|
||||||
|
const allowed = Math.max(0, entitled - used)
|
||||||
|
if (allowed <= 0) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.quantity > allowed) {
|
||||||
|
changed = true
|
||||||
|
next.push({ ...line, quantity: allowed })
|
||||||
|
} else {
|
||||||
|
next.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
onChange(next)
|
||||||
|
toast.add({
|
||||||
|
title: 'Free issue adjusted',
|
||||||
|
description: 'Free quantities were reduced to match what the current order qualifies for.',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accepted discount the order no longer qualifies for is dropped.
|
||||||
|
const stillValid = discountRef.current.filter((id) => result.some((r) => r.schemeId === id && r.eligible))
|
||||||
|
if (stillValid.length !== discountRef.current.length) {
|
||||||
|
onDiscountChange(stillValid)
|
||||||
|
toast.add({ title: 'Discount removed', description: 'The order no longer qualifies for the discount.', type: 'warning' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(s: SchemeSuggestion, remaining: number) {
|
||||||
|
if (!s.freeItemId) return
|
||||||
|
const existing = freeLines.find((l) => l.schemeId === s.schemeId)
|
||||||
|
if (existing) {
|
||||||
|
onChange(freeLines.map((l) => (l === existing ? { ...l, quantity: l.quantity + remaining } : l)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange([
|
||||||
|
...freeLines,
|
||||||
|
{
|
||||||
|
itemId: s.freeItemId,
|
||||||
|
itemName: s.freeItemName ?? 'Free item',
|
||||||
|
quantity: remaining,
|
||||||
|
schemeId: s.schemeId,
|
||||||
|
schemeName: s.schemeName,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuantity(index: number, quantity: number) {
|
||||||
|
onChange(freeLines.map((l, i) => (i === index ? { ...l, quantity } : l)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goods = suggestions.filter(isFreeGoods)
|
||||||
|
const offers = goods
|
||||||
|
.filter((s) => s.eligible && s.freeItemId)
|
||||||
|
.map((s) => {
|
||||||
|
const accepted = freeLines.filter((l) => l.schemeId === s.schemeId).reduce((sum, l) => sum + l.quantity, 0)
|
||||||
|
return { s, remaining: Math.floor(s.freeQty ?? 0) - accepted }
|
||||||
|
})
|
||||||
|
.filter((o) => o.remaining > 0)
|
||||||
|
const nudges = goods.filter((s) => !s.eligible && s.reason)
|
||||||
|
const discountOffers = suggestions.filter((s) => isDiscount(s) && s.eligible && s.discountAmount != null)
|
||||||
|
const applied = discountOffers.filter((s) => discountIds.includes(s.schemeId))
|
||||||
|
const available = discountOffers.filter((s) => !discountIds.includes(s.schemeId))
|
||||||
|
const orderTotal = paidLines.reduce((sum, l) => sum + (l.quantity || 0) * (l.unitPrice || 0), 0)
|
||||||
|
|
||||||
|
if (offers.length === 0 && nudges.length === 0 && freeLines.length === 0 && discountOffers.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Offers & Free Issues</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Suggested from active schemes. Add them to the order, then adjust the quantity if needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(applied.length > 0 || available.length > 0) && (
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
{applied.map((s) => (
|
||||||
|
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Discount applied: <span className="font-medium text-foreground">-{money(s.discountAmount!)}</span>
|
||||||
|
{' '}({money(orderTotal)} → {money(Math.max(0, orderTotal - s.discountAmount!))} on the invoice)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onDiscountChange([])}>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{available.map((s) => (
|
||||||
|
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Saves <span className="font-medium text-foreground">{money(s.discountAmount!)}</span> on this order
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" disabled={disabled || discountIds.length > 0} onClick={() => onDiscountChange([s.schemeId])}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offers.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
{offers.map(({ s, remaining }) => (
|
||||||
|
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-foreground">{s.schemeName}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Qualifies for <span className="font-medium text-foreground">{remaining} free {s.freeItemName ?? 'item'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" disabled={disabled} onClick={() => accept(s, remaining)}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Add free
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nudges.length > 0 && (
|
||||||
|
<ul className="mt-4 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
{nudges.map((s) => (
|
||||||
|
<li key={s.schemeId}>
|
||||||
|
<span className="font-medium text-foreground">{s.schemeName}:</span> {s.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{freeLines.length > 0 && (
|
||||||
|
<div className="mt-4 overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Free item</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Scheme</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-left font-medium">Free qty</th>
|
||||||
|
<th className="w-10 px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{freeLines.map((line, index) => (
|
||||||
|
<tr key={`${line.schemeId}-${index}`} className="border-t">
|
||||||
|
<td className="px-3 py-2">{line.itemName}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="outline">{line.schemeName}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={disabled}
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => setQuantity(index, Math.max(1, Math.floor(Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(freeLines.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Add01Icon, ArrowLeft01Icon, Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import {
|
||||||
|
invoices as invoicesApi,
|
||||||
|
users as usersApi,
|
||||||
|
items as itemsApi,
|
||||||
|
schemes as schemesApi,
|
||||||
|
priceLists as priceListsApi,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, SchemeSuggestion, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
|
interface AddedLine {
|
||||||
|
key: string
|
||||||
|
itemId: string
|
||||||
|
name: string
|
||||||
|
qty: string
|
||||||
|
price: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_PREFIX = 'new:'
|
||||||
|
const isNew = (item: InvoiceItemResponse) => item.id.startsWith(NEW_PREFIX)
|
||||||
|
const addedKey = (item: InvoiceItemResponse) => item.id.slice(NEW_PREFIX.length)
|
||||||
|
|
||||||
|
// Full-page editor for a Draft invoice, on the invoice template: change quantity/price, delete (strike)
|
||||||
|
// lines and add items that were not on the PO. Each save is recorded as a new invoice version.
|
||||||
|
export default function InvoiceEditPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const invoiceId = params.id
|
||||||
|
const backToInvoice = () => router.push(`/sales/invoices/${invoiceId}`)
|
||||||
|
|
||||||
|
const [full, setFull] = useState<InvoiceResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [qty, setQty] = useState<Record<string, string>>({})
|
||||||
|
const [price, setPrice] = useState<Record<string, string>>({})
|
||||||
|
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
||||||
|
const [added, setAdded] = useState<AddedLine[]>([])
|
||||||
|
const [catalogue, setCatalogue] = useState<ItemSummaryResponse[]>([])
|
||||||
|
const [pickItemId, setPickItemId] = useState('')
|
||||||
|
const [pickQty, setPickQty] = useState('1')
|
||||||
|
const [pickPrice, setPickPrice] = useState('')
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// Scheme discount for the lines as currently typed, recalculated by the server (null until the first answer).
|
||||||
|
const [previewDiscount, setPreviewDiscount] = useState<number | null>(null)
|
||||||
|
// The percentage-discount scheme applied to this invoice's order (at most one), and the ones it currently qualifies for.
|
||||||
|
const [discountIds, setDiscountIds] = useState<string[]>([])
|
||||||
|
const [discountOffers, setDiscountOffers] = useState<SchemeSuggestion[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoicesApi.get(invoiceId)
|
||||||
|
.then((inv) => {
|
||||||
|
if (inv.status !== 'Draft') {
|
||||||
|
toast.add({ title: 'Only Draft invoices can be edited', type: 'error' })
|
||||||
|
router.replace(`/sales/invoices/${invoiceId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFull(inv)
|
||||||
|
setDiscountIds(inv.discounts.map((d) => d.schemeId))
|
||||||
|
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
|
||||||
|
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||||
|
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
|
||||||
|
router.push('/sales/orders')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
usersApi.list()
|
||||||
|
.then(setUsersList)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
|
itemsApi.list({ pageSize: 200 })
|
||||||
|
.then((res) => setCatalogue(getPaginatedItems(res)))
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }))
|
||||||
|
}, [invoiceId, router])
|
||||||
|
|
||||||
|
// The template renders this copy so line amounts and the total follow what is typed.
|
||||||
|
const live = useMemo<InvoiceResponse | null>(() => {
|
||||||
|
if (!full) return null
|
||||||
|
const existing = full.items.map((i) => ({
|
||||||
|
...i,
|
||||||
|
quantity: Number(qty[i.id]) || 0,
|
||||||
|
unitPrice: Number(price[i.id]) || 0,
|
||||||
|
isRemoved: !!removed[i.id],
|
||||||
|
}))
|
||||||
|
const extra: InvoiceItemResponse[] = added.map((a) => ({
|
||||||
|
id: NEW_PREFIX + a.key,
|
||||||
|
shopPOItemId: null,
|
||||||
|
itemId: a.itemId,
|
||||||
|
itemName: a.name,
|
||||||
|
orderedQuantity: null,
|
||||||
|
quantity: Number(a.qty) || 0,
|
||||||
|
unitPrice: Number(a.price) || 0,
|
||||||
|
isRemoved: false,
|
||||||
|
isFree: false,
|
||||||
|
schemeId: null,
|
||||||
|
schemeName: null,
|
||||||
|
}))
|
||||||
|
const items = [...existing, ...extra]
|
||||||
|
const subtotal = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||||
|
const discountAmount = Math.min(previewDiscount ?? full.discountAmount, subtotal)
|
||||||
|
return { ...full, items, discountAmount, totalAmount: subtotal - discountAmount }
|
||||||
|
}, [full, qty, price, removed, added, previewDiscount])
|
||||||
|
|
||||||
|
// Adding, removing or re-pricing lines can change a percentage/slab discount, so ask the server to recalculate it.
|
||||||
|
const previewKey = JSON.stringify(
|
||||||
|
live?.items.map((i) => [i.itemId, i.quantity, i.unitPrice, i.isFree, i.isRemoved]) ?? [],
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!full || !live) return
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
invoicesApi
|
||||||
|
.previewTotals(full.id, live.items.map((i) => ({
|
||||||
|
itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice, isFree: i.isFree, isRemoved: i.isRemoved, schemeId: i.schemeId,
|
||||||
|
})), discountIds)
|
||||||
|
.then((r) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setPreviewDiscount(r.discountAmount)
|
||||||
|
// Lowering the paid item that earned a free issue lowers the free quantity with it.
|
||||||
|
const left = new Map(r.freeEntitlements.map((e) => [e.schemeId, e.quantity]))
|
||||||
|
let reduced = false
|
||||||
|
for (const it of live.items) {
|
||||||
|
if (!it.isFree || !it.schemeId || it.isRemoved) continue
|
||||||
|
const allowed = left.get(it.schemeId) ?? 0
|
||||||
|
if (it.quantity <= allowed) { left.set(it.schemeId, allowed - it.quantity); continue }
|
||||||
|
reduced = true
|
||||||
|
left.set(it.schemeId, 0)
|
||||||
|
if (allowed === 0) setRemoved((x) => ({ ...x, [it.id]: true }))
|
||||||
|
else setQty((x) => ({ ...x, [it.id]: String(allowed) }))
|
||||||
|
}
|
||||||
|
if (reduced) toast.add({ title: 'Free issue reduced', description: 'The free quantity was lowered to match the paid quantity.', type: 'warning' })
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the last known discount; the save recalculates it anyway */ })
|
||||||
|
}, 400)
|
||||||
|
return () => { cancelled = true; clearTimeout(timer) }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [full?.id, previewKey, discountIds])
|
||||||
|
|
||||||
|
// Which percentage discounts the lines as typed qualify for (so one can be applied or removed here).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!full || !live) return
|
||||||
|
const paid = live.items.filter((i) => !i.isFree && !i.isRemoved && i.quantity > 0)
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
schemesApi
|
||||||
|
.suggest({ storeId: full.storeId, items: paid.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice > 0 ? i.unitPrice : null })) })
|
||||||
|
.then((r) => { if (!cancelled) setDiscountOffers(r.filter((x) => (x.ruleType === 'FlatPercent' || x.ruleType === 'SlabDiscount') && x.eligible)) })
|
||||||
|
.catch(() => { if (!cancelled) setDiscountOffers([]) })
|
||||||
|
}, 400)
|
||||||
|
return () => { cancelled = true; clearTimeout(timer) }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [full?.id, previewKey])
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// Picking an item fills in its price: the store's price list, falling back to the item's default price.
|
||||||
|
async function pickItem(itemId: string) {
|
||||||
|
setPickItemId(itemId)
|
||||||
|
const item = catalogue.find((c) => c.id === itemId)
|
||||||
|
if (!item || !full) return
|
||||||
|
setPickPrice(String(item.salePrice))
|
||||||
|
try {
|
||||||
|
const res = await priceListsApi.resolve({
|
||||||
|
itemId,
|
||||||
|
storeId: full.storeId,
|
||||||
|
saleDate: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
setPickPrice(String(res.price))
|
||||||
|
} catch {
|
||||||
|
// No price list entry for this store — keep the default price.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The line currently typed into the add row, or null when nothing valid is picked.
|
||||||
|
function pendingLine(): AddedLine | null {
|
||||||
|
const item = catalogue.find((c) => c.id === pickItemId)
|
||||||
|
if (!item || !(Number(pickQty) > 0) || pickPrice === '' || Number(pickPrice) < 0) return null
|
||||||
|
return { key: crypto.randomUUID(), itemId: item.id, name: item.name, qty: pickQty, price: pickPrice }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
const line = pendingLine()
|
||||||
|
if (!line) {
|
||||||
|
toast.add({ title: 'Pick an item and enter a quantity above zero', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAdded((a) => [...a, line])
|
||||||
|
setPickItemId('')
|
||||||
|
setPickQty('1')
|
||||||
|
setPickPrice('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeSummary = useMemo(() => {
|
||||||
|
if (!full) return []
|
||||||
|
const edited = full.items.filter(
|
||||||
|
(i) => !removed[i.id] && (Number(qty[i.id]) !== i.quantity || Number(price[i.id]) !== i.unitPrice),
|
||||||
|
).length
|
||||||
|
const struck = full.items.filter((i) => !!removed[i.id] !== i.isRemoved).length
|
||||||
|
return [
|
||||||
|
added.length && `${added.length} added`,
|
||||||
|
struck && `${struck} removed/restored`,
|
||||||
|
edited && `${edited} edited`,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
}, [full, qty, price, removed, added])
|
||||||
|
|
||||||
|
const dirty = changeSummary.length > 0
|
||||||
|
|
||||||
|
// Warn before the tab is closed/reloaded with unsaved edits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return
|
||||||
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
|
||||||
|
window.addEventListener('beforeunload', warn)
|
||||||
|
return () => window.removeEventListener('beforeunload', warn)
|
||||||
|
}, [dirty])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!full) return
|
||||||
|
if (!userId) {
|
||||||
|
toast.add({ title: 'Select who is editing', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// An item picked in the add row but not yet added is included, so it is not silently lost.
|
||||||
|
const pending = pendingLine()
|
||||||
|
const newLines = pending ? [...added, pending] : added
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await invoicesApi.updateItems(
|
||||||
|
full.id,
|
||||||
|
[
|
||||||
|
...full.items.map((i) => ({
|
||||||
|
invoiceItemId: i.id,
|
||||||
|
quantity: Number(qty[i.id]),
|
||||||
|
unitPrice: Number(price[i.id]),
|
||||||
|
isRemoved: !!removed[i.id],
|
||||||
|
})),
|
||||||
|
...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
|
||||||
|
],
|
||||||
|
userId,
|
||||||
|
discountIds,
|
||||||
|
)
|
||||||
|
toast.add({ title: 'Invoice updated', type: 'success' })
|
||||||
|
// Navigating away right after a successful save; clear the unsaved-changes guard first.
|
||||||
|
setAdded([])
|
||||||
|
setQty(Object.fromEntries(full.items.map((i) => [i.id, String(i.quantity)])))
|
||||||
|
setPrice(Object.fromEntries(full.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||||
|
setRemoved(Object.fromEntries(full.items.map((i) => [i.id, i.isRemoved])))
|
||||||
|
backToInvoice()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to update invoice', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={backToInvoice}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Edit Draft Invoice</h1>
|
||||||
|
{full && <Badge variant="secondary">{full.status}</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Change quantities and prices, delete lines, or add items. Quantity on a PO line cannot exceed the
|
||||||
|
ordered quantity. Every save creates a new version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || !live ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading invoice…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(discountOffers.length > 0 || discountIds.length > 0) && (
|
||||||
|
<div className="mx-auto flex w-full max-w-[210mm] flex-col gap-2 rounded-xl border p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">Scheme discount</p>
|
||||||
|
{discountOffers.map((o) => {
|
||||||
|
const applied = discountIds.includes(o.schemeId)
|
||||||
|
return (
|
||||||
|
<div key={o.schemeId} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{o.schemeName}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">saves Rs. {(o.discountAmount ?? 0).toLocaleString()} on these lines</span>
|
||||||
|
</span>
|
||||||
|
{applied ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="sm" disabled={discountIds.length > 0} onClick={() => setDiscountIds([o.schemeId])}>
|
||||||
|
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{discountIds.length > 0 && !discountOffers.some((o) => discountIds.includes(o.schemeId)) && (
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>An applied discount no longer qualifies for these lines (it counts as Rs. 0).</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InvoiceTemplate
|
||||||
|
invoice={live}
|
||||||
|
renderQuantity={(item) => (
|
||||||
|
<Input
|
||||||
|
type="number" min={0} max={item.orderedQuantity ?? undefined}
|
||||||
|
className="ml-auto h-8 w-20 text-right"
|
||||||
|
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.qty ?? '' : qty[item.id] ?? ''}
|
||||||
|
onChange={(e) => isNew(item)
|
||||||
|
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, qty: e.target.value } : l))
|
||||||
|
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderUnitPrice={(item) => item.isFree ? (
|
||||||
|
<span className="text-sm text-muted-foreground">Free</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="number" min={0} step="0.01"
|
||||||
|
className="ml-auto h-8 w-28 text-right"
|
||||||
|
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.price ?? '' : price[item.id] ?? ''}
|
||||||
|
onChange={(e) => isNew(item)
|
||||||
|
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, price: e.target.value } : l))
|
||||||
|
: setPrice((p) => ({ ...p, [item.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderActions={(item) => (
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon-sm"
|
||||||
|
className={item.isRemoved ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
|
||||||
|
aria-label={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||||
|
title={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||||
|
onClick={() => isNew(item)
|
||||||
|
? setAdded((a) => a.filter((l) => l.key !== addedKey(item)))
|
||||||
|
: setRemoved((r) => ({ ...r, [item.id]: !r[item.id] }))}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={item.isRemoved ? Undo02Icon : Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
footerRow={
|
||||||
|
// Cells line up with the table columns: Item · Qty · Unit Price · Amount · action.
|
||||||
|
<tr className="bg-neutral-50 print:hidden" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<Select value={pickItemId} onValueChange={(v) => pickItem(v ?? '')}>
|
||||||
|
<SelectTrigger className="h-8 w-full"><SelectValue placeholder="Add an item…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableItems.length === 0
|
||||||
|
? <div className="px-3 py-2 text-sm text-muted-foreground">No more items to add</div>
|
||||||
|
: availableItems.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name} <span className="text-muted-foreground">· {c.sku}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<Input
|
||||||
|
type="number" min={1} aria-label="Quantity" className="ml-auto h-8 w-20 text-right"
|
||||||
|
value={pickQty} onChange={(e) => setPickQty(e.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<Input
|
||||||
|
type="number" min={0} step="0.01" aria-label="Unit price" placeholder="Price"
|
||||||
|
className="ml-auto h-8 w-28 text-right"
|
||||||
|
value={pickPrice} onChange={(e) => setPickPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3 text-right text-sm text-neutral-500">
|
||||||
|
{pickItemId ? ((Number(pickQty) || 0) * (Number(pickPrice) || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 }) : ''}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
type="button" size="icon-sm" aria-label="Add item" title="Add item (Enter)"
|
||||||
|
onClick={addItem} disabled={!pickItemId}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-full max-w-[210mm] flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="w-64">
|
||||||
|
<Select value={userId} onValueChange={(v) => setUserId(v ?? '')}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Edited by (temporary)..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{usersList.map((u) => <SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dirty ? `Unsaved: ${changeSummary.join(', ')}` : 'No changes yet'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button>
|
||||||
|
<Button type="button" onClick={save} disabled={saving || !userId}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,12 +8,19 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
||||||
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
import type { InvoiceResponse, InvoiceStatus, InvoiceVersionResponse, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||||
|
|
||||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||||
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
||||||
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
|
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
|
||||||
|
|
||||||
|
const STAGE_LABEL: Record<InvoiceVersionResponse['stage'], string> = {
|
||||||
|
Created: 'Created',
|
||||||
|
DraftEdited: 'Draft edited',
|
||||||
|
Printed: 'Printed',
|
||||||
|
Finalized: 'Finalized',
|
||||||
|
}
|
||||||
|
|
||||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
||||||
Draft: 'secondary',
|
Draft: 'secondary',
|
||||||
Printed: 'outline',
|
Printed: 'outline',
|
||||||
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
|
|||||||
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
||||||
const [actionOpen, setActionOpen] = useState(false)
|
const [actionOpen, setActionOpen] = useState(false)
|
||||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||||
|
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
|
||||||
|
|
||||||
const fetchInvoice = useCallback(async () => {
|
const fetchInvoice = useCallback(async () => {
|
||||||
const inv = await invoicesApi.get(params.id)
|
const [inv, vers] = await Promise.all([invoicesApi.get(params.id), invoicesApi.versions(params.id)])
|
||||||
setInvoice(inv)
|
setInvoice(inv)
|
||||||
|
setVersions(vers)
|
||||||
return inv
|
return inv
|
||||||
}, [params.id])
|
}, [params.id])
|
||||||
|
|
||||||
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{invoice.status === 'Draft' && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.push(`/sales/invoices/${invoice.id}/edit`)}>
|
||||||
|
Edit Items
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{invoice.status === 'Draft' && (
|
{invoice.status === 'Draft' && (
|
||||||
<Button type="button" onClick={handlePrint}>
|
<Button type="button" onClick={handlePrint}>
|
||||||
Print Invoice
|
Print Invoice
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{invoice.status !== 'Draft' && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
|
||||||
|
Reprint
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{invoice.status === 'Printed' && (
|
{invoice.status === 'Printed' && (
|
||||||
<Button type="button" onClick={handleFinalize}>
|
<Button type="button" onClick={handleFinalize}>
|
||||||
Finalize Invoice
|
Finalize Invoice
|
||||||
@@ -160,6 +179,7 @@ export default function InvoiceViewPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
||||||
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
|
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
|
||||||
|
{invoice.discountAmount > 0 && <Stat label="Scheme discount (included)" value={fmtAmount(invoice.discountAmount)} />}
|
||||||
<Stat label="Paid" value={fmtAmount(paidAmount)} />
|
<Stat label="Paid" value={fmtAmount(paidAmount)} />
|
||||||
<Stat label="Balance" value={fmtAmount(balance)} />
|
<Stat label="Balance" value={fmtAmount(balance)} />
|
||||||
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
|
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
|
||||||
@@ -167,6 +187,109 @@ export default function InvoiceViewPage() {
|
|||||||
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Ordered quantity is the original PO; quantity is what is billed now.</p>
|
||||||
|
</div>
|
||||||
|
<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="w-24 px-3 py-2 text-right font-medium">Ordered</th>
|
||||||
|
<th className="w-24 px-3 py-2 text-right font-medium">Billed</th>
|
||||||
|
<th className="w-24 px-3 py-2 text-right font-medium">Free Issue</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Unit price</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
|
||||||
|
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{i.itemName}
|
||||||
|
{i.isFree && <Badge variant="outline" className="ml-2 no-underline">{i.schemeName ?? "Free issue"}</Badge>}
|
||||||
|
{i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
|
||||||
|
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{i.isFree ? '-' : (i.orderedQuantity ?? '-')}</td>
|
||||||
|
<td className={`px-3 py-2 text-right ${!i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||||
|
{i.isFree ? '-' : i.quantity}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-2 text-right ${i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||||
|
{i.isFree ? i.quantity : '-'}
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
{invoice.items.length > 0 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t bg-muted/30">
|
||||||
|
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Subtotal</td>
|
||||||
|
<td className="px-3 py-2 text-right text-sm font-medium">
|
||||||
|
{fmtAmount(invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{invoice.discountAmount > 0 && (
|
||||||
|
<tr className="bg-muted/30">
|
||||||
|
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount</td>
|
||||||
|
<td className="px-3 py-2 text-right text-sm font-medium">-{fmtAmount(invoice.discountAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr className="border-t bg-muted/30">
|
||||||
|
<td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">Total</td>
|
||||||
|
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">{fmtAmount(invoice.totalAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Version history</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Quantities after each change, so you can compare the original, printed and finalized copies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="w-16 px-3 py-2 text-left font-medium">Version</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-left font-medium">Stage</th>
|
||||||
|
<th className="w-40 px-3 py-2 text-left font-medium">When</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Quantities</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-right font-medium">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{versions.map((v) => (
|
||||||
|
<tr key={v.id} className="border-t align-top">
|
||||||
|
<td className="px-3 py-2 font-mono">v{v.versionNumber}</td>
|
||||||
|
<td className="px-3 py-2"><Badge variant="outline">{STAGE_LABEL[v.stage]}</Badge></td>
|
||||||
|
<td className="px-3 py-2">{fmtDateTime(v.createdAt)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{v.items.map((i) => (i.isRemoved ? `${i.itemName} (removed)` : `${i.itemName} × ${i.quantity}`)).join(', ') || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium">{fmtAmount(v.totalAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border p-6">
|
<div className="rounded-2xl border p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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'),
|
||||||
@@ -57,6 +58,8 @@ 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),
|
||||||
@@ -125,9 +128,13 @@ 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: values.items,
|
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')
|
||||||
@@ -343,6 +350,19 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { invoices as invoicesApi, shopPos as shopPosApi, users as usersApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { ShopPOResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
shopPOId: z.string().min(1, 'Shop PO is required'),
|
||||||
|
userId: z.string().min(1, 'User is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
interface CreateInvoiceDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
const truncId = (id: string) => id.substring(0, 8)
|
||||||
|
|
||||||
|
export function CreateInvoiceDialog({ open, onClose, onSuccess }: CreateInvoiceDialogProps) {
|
||||||
|
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
||||||
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { shopPOId: '', userId: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
form.reset({ shopPOId: '', userId: '' })
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([
|
||||||
|
shopPosApi.confirmedWithoutInvoice(),
|
||||||
|
usersApi.list(),
|
||||||
|
])
|
||||||
|
.then(([posRes, usersRes]) => {
|
||||||
|
setPos(posRes)
|
||||||
|
setUsersList(usersRes)
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load data', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
await invoicesApi.create(values.shopPOId, values.userId)
|
||||||
|
toast.add({ title: 'Invoice created', type: 'success' })
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to create invoice', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Invoice</DialogTitle>
|
||||||
|
<DialogDescription>Generate an invoice from a confirmed Shop PO that doesn't have one yet.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="shopPOId"
|
||||||
|
label="Shop PO"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading || pos.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loading ? 'Loading…' : pos.length === 0 ? 'No eligible Shop POs' : 'Select Shop PO...'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pos.map((po) => (
|
||||||
|
<SelectItem key={po.id} value={po.id}>
|
||||||
|
{po.storeName} — {fmtAmount(po.totalAmount)} [{truncId(po.id)}]
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="userId"
|
||||||
|
label="Created By"
|
||||||
|
description="Temporary — required until auth resolves the logged-in user automatically."
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{usersList.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting || pos.length === 0}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : 'Create Invoice'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'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 {
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { InvoiceResponse, UserResponse } from '@/types/api'
|
import type { InvoiceResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
@@ -39,13 +42,17 @@ interface InvoiceActionDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COPY = {
|
const COPY = {
|
||||||
print: { title: 'Mark Invoice as Printed', label: 'Printed By', action: 'Mark as Printed', success: 'Invoice marked as printed' },
|
print: { title: 'Print Invoice', label: 'Printed By', action: 'Print Invoice', success: 'Invoice printed' },
|
||||||
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
|
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
||||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
const copy = COPY[mode]
|
const copy = COPY[mode]
|
||||||
|
const [qty, setQty] = useState<Record<string, string>>({})
|
||||||
|
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
||||||
|
// Rows from the list endpoint carry no line items, so the full invoice is loaded for the finalize table.
|
||||||
|
const [full, setFull] = useState<InvoiceResponse | null>(null)
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -55,6 +62,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ userId: '' })
|
form.reset({ userId: '' })
|
||||||
|
setFull(null)
|
||||||
|
setQty({})
|
||||||
|
setRemoved({})
|
||||||
|
if (invoice) {
|
||||||
|
invoicesApi.get(invoice.id)
|
||||||
|
.then((inv) => {
|
||||||
|
setFull(inv)
|
||||||
|
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
|
||||||
|
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' }))
|
||||||
|
}
|
||||||
usersApi.list()
|
usersApi.list()
|
||||||
.then((res) => setUsersList(res))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
@@ -62,21 +81,35 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
|
|
||||||
if (!invoice) return null
|
if (!invoice) return null
|
||||||
|
|
||||||
|
const lines = full?.items ?? []
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
|
// Opened synchronously, while the click still counts as a user gesture, so the browser allows the new tab.
|
||||||
|
const printTab = mode === 'print' ? window.open('', '_blank') : null
|
||||||
try {
|
try {
|
||||||
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
|
if (mode === 'print') {
|
||||||
else await invoicesApi.finalize(invoice!.id, values.userId)
|
await invoicesApi.print(invoice!.id, values.userId)
|
||||||
|
if (printTab) printTab.location.href = `/invoices/${invoice!.id}/print`
|
||||||
|
} else {
|
||||||
|
if (!full) throw new Error('Invoice is still loading')
|
||||||
|
// Only lines the agent changed on the printed copy are sent as adjustments.
|
||||||
|
const adjustments = lines
|
||||||
|
.filter((i) => Number(qty[i.id]) !== i.quantity || !!removed[i.id] !== i.isRemoved)
|
||||||
|
.map((i) => ({ invoiceItemId: i.id, adjustedQuantity: Number(qty[i.id]), isRemoved: !!removed[i.id] }))
|
||||||
|
await invoicesApi.finalize(invoice!.id, values.userId, adjustments)
|
||||||
|
}
|
||||||
toast.add({ title: copy.success, type: 'success' })
|
toast.add({ title: copy.success, type: 'success' })
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
printTab?.close()
|
||||||
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{copy.title}</DialogTitle>
|
<DialogTitle>{copy.title}</DialogTitle>
|
||||||
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
||||||
@@ -106,6 +139,51 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
{mode === 'finalize' && lines.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Edits from the printed copy</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="w-20 px-3 py-2 text-right font-medium">Printed</th>
|
||||||
|
<th className="w-24 px-3 py-2 text-right font-medium">Final qty</th>
|
||||||
|
<th className="w-24 px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((i) => (
|
||||||
|
<tr key={i.id} className={`border-t ${removed[i.id] ? "text-muted-foreground line-through" : ""}`}>
|
||||||
|
<td className="px-3 py-2">{i.itemName}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{i.quantity}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number" min={0} max={i.orderedQuantity ?? undefined} disabled={!!removed[i.id]}
|
||||||
|
className="h-8 text-right"
|
||||||
|
value={qty[i.id] ?? ''}
|
||||||
|
onChange={(e) => setQty((q) => ({ ...q, [i.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right no-underline">
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon-sm"
|
||||||
|
className={removed[i.id] ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
|
||||||
|
aria-label={removed[i.id] ? 'Restore line' : 'Delete line'}
|
||||||
|
title={removed[i.id] ? 'Restore line' : 'Delete line'}
|
||||||
|
onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={removed[i.id] ? Undo02Icon : Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
|||||||
@@ -1,35 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||||
|
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
|
import type { InvoiceResponse } from '@/types/api'
|
||||||
Draft: 'secondary',
|
|
||||||
Printed: 'secondary',
|
|
||||||
Finalized: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
|
|
||||||
Pending: 'secondary',
|
|
||||||
PartiallyPaid: 'secondary',
|
|
||||||
Paid: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
|
||||||
const truncId = (id: string) => id.substring(0, 8)
|
|
||||||
|
|
||||||
const fmtDateTime = (v: string | null) =>
|
|
||||||
v
|
|
||||||
? new Date(v).toLocaleString('en-GB', {
|
|
||||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
||||||
})
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
interface OrderViewSheetProps {
|
interface OrderViewSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
|
|||||||
invoice: InvoiceResponse | null
|
invoice: InvoiceResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
|
||||||
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 OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
||||||
|
const doc = useInvoiceDocument(invoice?.id, open)
|
||||||
|
|
||||||
if (!invoice) return null
|
if (!invoice) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Invoice Details</DialogTitle>
|
<DialogTitle>Invoice Preview</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{doc.invoice ? (
|
||||||
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
|
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
|
||||||
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
|
) : (
|
||||||
<Row label="Store" value={invoice.storeName} />
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
|
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
|
||||||
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
|
</p>
|
||||||
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
|
)}
|
||||||
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
|
|
||||||
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
|
{doc.invoice && doc.invoice.status !== 'Draft' && (
|
||||||
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
|
||||||
|
Open print view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
import { DataTable } from '@/components/shared/data-table'
|
import { DataTable } from '@/components/shared/data-table'
|
||||||
import type { TableConfig } from '@/components/shared/data-table'
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { invoices as invoicesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { invoices as invoicesApi, invoiceCreationMode as invoiceCreationModeApi, DEFAULT_INVOICE_CREATION_MODE, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
||||||
|
|
||||||
import { OrderViewSheet } from './order-view-sheet'
|
import { OrderViewSheet } from './order-view-sheet'
|
||||||
import { InvoiceActionDialog } from './invoice-action-dialog'
|
import { InvoiceActionDialog } from './invoice-action-dialog'
|
||||||
|
import { CreateInvoiceDialog } from './create-invoice-dialog'
|
||||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
@@ -32,6 +36,7 @@ const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'd
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OrdersClient() {
|
export function OrdersClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<InvoiceResponse[]>([])
|
const [data, setData] = useState<InvoiceResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -44,6 +49,12 @@ export function OrdersClient() {
|
|||||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||||
const [paymentOpen, setPaymentOpen] = useState(false)
|
const [paymentOpen, setPaymentOpen] = useState(false)
|
||||||
const [paymentInvoice, setPaymentInvoice] = useState<InvoiceResponse | null>(null)
|
const [paymentInvoice, setPaymentInvoice] = useState<InvoiceResponse | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -67,6 +78,10 @@ export function OrdersClient() {
|
|||||||
setViewOpen(true)
|
setViewOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(row: InvoiceResponse) {
|
||||||
|
router.push(`/sales/invoices/${row.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
function handlePrint(row: InvoiceResponse) {
|
function handlePrint(row: InvoiceResponse) {
|
||||||
setSelectedInvoice(row)
|
setSelectedInvoice(row)
|
||||||
setActionMode('print')
|
setActionMode('print')
|
||||||
@@ -147,12 +162,24 @@ export function OrdersClient() {
|
|||||||
label: 'View invoice',
|
label: 'View invoice',
|
||||||
onClick: handleView,
|
onClick: handleView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: 'edit',
|
||||||
|
label: 'Edit draft items',
|
||||||
|
onClick: handleEdit,
|
||||||
|
visible: (target) => target.status === 'Draft',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'print',
|
action: 'print',
|
||||||
label: 'Mark as printed',
|
label: 'Print invoice',
|
||||||
onClick: handlePrint,
|
onClick: handlePrint,
|
||||||
visible: (target) => target.status === 'Draft',
|
visible: (target) => target.status === 'Draft',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: 'print',
|
||||||
|
label: 'Reprint invoice',
|
||||||
|
onClick: (target) => window.open(`/invoices/${target.id}/print`, '_blank'),
|
||||||
|
visible: (target) => target.status !== 'Draft',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'finalize',
|
action: 'finalize',
|
||||||
label: 'Finalize invoice',
|
label: 'Finalize invoice',
|
||||||
@@ -178,6 +205,9 @@ export function OrdersClient() {
|
|||||||
Billing documents generated from confirmed Shop POs
|
Billing documents generated from confirmed Shop POs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{mode === 'ModeB' && (
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>Create Invoice</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -212,6 +242,12 @@ export function OrdersClient() {
|
|||||||
invoices={paymentInvoice ? [paymentInvoice] : []}
|
invoices={paymentInvoice ? [paymentInvoice] : []}
|
||||||
defaultInvoiceId={paymentInvoice?.id}
|
defaultInvoiceId={paymentInvoice?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateInvoiceDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'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,8 +13,6 @@ 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[]>([])
|
||||||
@@ -22,9 +20,6 @@ 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 {
|
||||||
@@ -42,7 +37,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) { setViewing(role); setViewOpen(true) }
|
function openView(role: RoleResponse) { router.push(`/users/roles/${role.id}`) }
|
||||||
|
|
||||||
async function handleDeactivate(role: RoleResponse) {
|
async function handleDeactivate(role: RoleResponse) {
|
||||||
try {
|
try {
|
||||||
@@ -120,12 +115,6 @@ 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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { InvoiceTemplate, type InvoicePaper } from '@/components/invoice/invoice-template'
|
||||||
|
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||||
|
|
||||||
|
// Browser page rule per paper size. Receipts are one continuous strip (auto height). Margin is 0 so the
|
||||||
|
// browser has no margin area to print its own header/footer (page title, URL, date) into; the sheet pads itself.
|
||||||
|
const PAGE_RULE: Record<InvoicePaper, string> = {
|
||||||
|
A4: '@page { size: A4; margin: 0; }',
|
||||||
|
'80mm': '@page { size: 80mm auto; margin: 0; }',
|
||||||
|
'58mm': '@page { size: 58mm auto; margin: 0; }',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone printable invoice (no dashboard chrome). Opened in a new tab after "Print".
|
||||||
|
export default function InvoicePrintPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const { invoice, payments, customer, error } = useInvoiceDocument(params.id)
|
||||||
|
const [paper, setPaper] = useState<InvoicePaper>('A4')
|
||||||
|
|
||||||
|
// Open the browser print dialog once, when the invoice first appears.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoice) return
|
||||||
|
const t = setTimeout(() => window.print(), 600)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [invoice])
|
||||||
|
|
||||||
|
if (error) return <p className="p-8 text-sm text-destructive">Failed to load invoice: {error}</p>
|
||||||
|
if (!invoice) return <p className="p-8 text-sm text-muted-foreground">Loading invoice…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl p-6 print:max-w-none print:p-0">
|
||||||
|
<style>{`${PAGE_RULE[paper]} @media print { html, body { background: #fff !important; } }`}</style>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center justify-end gap-2 print:hidden">
|
||||||
|
<div className="w-32">
|
||||||
|
<Select value={paper} onValueChange={(v) => setPaper((v as InvoicePaper) ?? 'A4')}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Paper size" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="A4">A4</SelectItem>
|
||||||
|
<SelectItem value="80mm">80mm receipt</SelectItem>
|
||||||
|
<SelectItem value="58mm">58mm receipt</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.close()}>Close</Button>
|
||||||
|
<Button type="button" onClick={() => window.print()}>Print</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InvoiceTemplate
|
||||||
|
invoice={invoice}
|
||||||
|
payments={payments}
|
||||||
|
customer={customer}
|
||||||
|
paper={paper}
|
||||||
|
showAgentEdits={invoice.status !== 'Finalized'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Shared number/date formatting for printed invoices.
|
||||||
|
|
||||||
|
/** `Rs. 12,450.00` — thousands separator, exactly 2 decimals. */
|
||||||
|
export const fmtMoney = (n: number) =>
|
||||||
|
`Rs. ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
|
||||||
|
/** Quantity: up to 3 decimals, trailing zeros dropped. */
|
||||||
|
export const fmtQty = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 3 })
|
||||||
|
|
||||||
|
/** `dd/MM/yyyy` */
|
||||||
|
export function fmtDate(v: string | null | undefined) {
|
||||||
|
if (!v) return '—'
|
||||||
|
const d = new Date(v)
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${dd}/${mm}/${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoiceNo = (id: string) => `INV-${id.substring(0, 8).toUpperCase()}`
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { InvoiceItemResponse, InvoiceResponse, PaymentResponse } from '@/types/api'
|
||||||
|
import { fmtDate, fmtMoney, fmtQty, invoiceNo } from './invoice-format'
|
||||||
|
|
||||||
|
export type InvoicePaper = 'A4' | '80mm' | '58mm'
|
||||||
|
|
||||||
|
export interface InvoiceCustomer {
|
||||||
|
address?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceTemplateProps {
|
||||||
|
invoice: InvoiceResponse
|
||||||
|
payments?: PaymentResponse[]
|
||||||
|
customer?: InvoiceCustomer
|
||||||
|
paper?: InvoicePaper
|
||||||
|
// Draft-editor hooks (A4 only): inputs in the qty / price cells, a per-row action, and an extra table row.
|
||||||
|
renderQuantity?: (item: InvoiceItemResponse) => ReactNode
|
||||||
|
renderUnitPrice?: (item: InvoiceItemResponse) => ReactNode
|
||||||
|
renderActions?: (item: InvoiceItemResponse) => ReactNode
|
||||||
|
footerRow?: ReactNode
|
||||||
|
// Blank lines for the agent's handwritten edits on the paper copy.
|
||||||
|
showAgentEdits?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft slate palette: light rules, muted section headings.
|
||||||
|
const RULE = '#cbd5e1'
|
||||||
|
const HEADING = '#64748b'
|
||||||
|
|
||||||
|
const statusLabel = (invoice: InvoiceResponse) =>
|
||||||
|
invoice.status === 'Finalized' ? null : invoice.status.toUpperCase()
|
||||||
|
|
||||||
|
// Removed lines stay on the document (struck through) but are excluded from every total.
|
||||||
|
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
|
||||||
|
const subtotal = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||||
|
const discount = Math.min(invoice.discountAmount ?? 0, subtotal)
|
||||||
|
const total = subtotal - discount
|
||||||
|
const paid = payments.reduce((s, p) => s + p.amount, 0)
|
||||||
|
return { subtotal, discount, total, paid, balance: total - paid }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
|
||||||
|
return paper === 'A4' ? <A4Sheet {...props} /> : <ReceiptSheet {...props} paper={paper} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── A4 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function A4Sheet({
|
||||||
|
invoice,
|
||||||
|
payments = [],
|
||||||
|
customer,
|
||||||
|
renderQuantity,
|
||||||
|
renderUnitPrice,
|
||||||
|
renderActions,
|
||||||
|
footerRow,
|
||||||
|
showAgentEdits,
|
||||||
|
className,
|
||||||
|
}: Omit<InvoiceTemplateProps, 'paper'>) {
|
||||||
|
const { subtotal, discount, total, paid, balance } = totals(invoice, payments)
|
||||||
|
const status = statusLabel(invoice)
|
||||||
|
const hasRemoved = invoice.items.some((i) => i.isRemoved)
|
||||||
|
const hasFree = invoice.items.some((i) => i.isFree)
|
||||||
|
// Free issues are listed after every paid line.
|
||||||
|
const rows = [...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'invoice-sheet relative mx-auto w-full max-w-[210mm] rounded-lg border bg-white p-10 text-sm leading-relaxed text-neutral-900 shadow-sm',
|
||||||
|
'print:max-w-none print:rounded-none print:border-0 print:p-[14mm] print:shadow-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-6 border-b pb-5" style={{ borderColor: RULE }}>
|
||||||
|
<h2 className="text-4xl font-bold tracking-wide">INVOICE</h2>
|
||||||
|
<dl className="grid grid-cols-[auto_auto] gap-x-6 gap-y-1 text-right text-sm">
|
||||||
|
<dt className="text-neutral-500">Invoice No.</dt>
|
||||||
|
<dd className="font-mono font-semibold">{invoiceNo(invoice.id)}</dd>
|
||||||
|
<dt className="text-neutral-500">Invoice Date</dt>
|
||||||
|
<dd>{fmtDate(invoice.createdAt)}</dd>
|
||||||
|
<dt className="text-neutral-500">PO Ref.</dt>
|
||||||
|
<dd className="font-mono">{invoice.shopPOId.substring(0, 8).toUpperCase()}</dd>
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
|
<dt className="text-neutral-500">Status</dt>
|
||||||
|
<dd className="font-semibold" style={{ color: HEADING }}>{status}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bill to */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||||
|
Bill To
|
||||||
|
</h3>
|
||||||
|
<p className="text-base font-semibold">{invoice.storeName}</p>
|
||||||
|
{customer?.address && <p className="text-neutral-600">{customer.address}</p>}
|
||||||
|
{customer?.phone && <p className="text-neutral-600">{customer.phone}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="mb-2 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||||
|
Item Details
|
||||||
|
</h3>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
<th className="px-2 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
|
||||||
|
{hasFree && <th className="w-24 px-2 py-2 text-right font-medium">Free Issue</th>}
|
||||||
|
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
|
||||||
|
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
|
||||||
|
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.items.length === 0 && !footerRow ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-2.5">
|
||||||
|
{item.itemName}
|
||||||
|
{item.isFree && item.schemeName && (
|
||||||
|
<span className="block text-xs text-neutral-500 no-underline">Free issue · {item.schemeName}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5 text-right">
|
||||||
|
{item.isFree ? '-' : renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
|
||||||
|
</td>
|
||||||
|
{hasFree && (
|
||||||
|
<td className="px-2 py-2.5 text-right">
|
||||||
|
{item.isFree ? (renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)) : '-'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-2 py-2.5 text-right">
|
||||||
|
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5 text-right font-medium">{fmtMoney(item.quantity * item.unitPrice)}</td>
|
||||||
|
{renderActions && <td className="px-2 py-2.5 text-right no-underline print:hidden">{renderActions(item)}</td>}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{footerRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{hasRemoved && (
|
||||||
|
<p className="mt-2 text-xs text-neutral-500">Struck-through lines were removed and are not charged.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes + totals */}
|
||||||
|
<div className="mt-6 grid grid-cols-[1fr_auto] gap-10">
|
||||||
|
<div>
|
||||||
|
{showAgentEdits && (
|
||||||
|
<>
|
||||||
|
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||||
|
Notes / Agent Edits
|
||||||
|
</h3>
|
||||||
|
{[0, 1, 2, 3].map((n) => (
|
||||||
|
<div key={n} className="h-7 border-b border-dotted border-neutral-400" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<dl className="w-72 break-inside-avoid text-sm">
|
||||||
|
<div className="flex justify-between border-b py-1.5">
|
||||||
|
<dt className="text-neutral-500">Subtotal</dt>
|
||||||
|
<dd>{fmtMoney(subtotal)}</dd>
|
||||||
|
</div>
|
||||||
|
{discount > 0 && (
|
||||||
|
<div className="flex justify-between border-b py-1.5">
|
||||||
|
<dt className="text-neutral-500">Scheme discount</dt>
|
||||||
|
<dd>-{fmtMoney(discount)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd>{fmtMoney(total)}</dd>
|
||||||
|
</div>
|
||||||
|
{paid > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between py-1.5">
|
||||||
|
<dt className="text-neutral-500">Paid</dt>
|
||||||
|
<dd>{fmtMoney(paid)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t py-1.5 font-semibold">
|
||||||
|
<dt>Balance Due</dt>
|
||||||
|
<dd>{fmtMoney(balance)}</dd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
<div className="mt-6 break-inside-avoid">
|
||||||
|
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||||
|
Payments
|
||||||
|
</h3>
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<p className="text-neutral-500">No payment recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{payments.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b border-neutral-100">
|
||||||
|
<td className="w-28 py-1">{fmtDate(p.collectedAt)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.method}
|
||||||
|
{p.chequeNumber && <span className="text-neutral-500"> · Cheque {p.chequeNumber}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-medium">{fmtMoney(p.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signatures */}
|
||||||
|
<div className="mt-14 grid grid-cols-2 gap-16 text-xs text-neutral-600">
|
||||||
|
<div className="border-t border-neutral-400 pt-1">Authorised by</div>
|
||||||
|
<div className="border-t border-neutral-400 pt-1">Received by (customer signature)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 80mm / 58mm thermal receipt ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReceiptSheet({
|
||||||
|
invoice,
|
||||||
|
payments = [],
|
||||||
|
customer,
|
||||||
|
paper,
|
||||||
|
showAgentEdits,
|
||||||
|
className,
|
||||||
|
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
|
||||||
|
const { subtotal, discount, total, paid, balance } = totals(invoice, payments)
|
||||||
|
const status = statusLabel(invoice)
|
||||||
|
const narrow = paper === '58mm'
|
||||||
|
const rule = <div className="my-2 border-t border-dashed border-black" />
|
||||||
|
const num = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'invoice-sheet mx-auto bg-white font-mono text-black shadow-sm print:shadow-none',
|
||||||
|
narrow ? 'w-[58mm] px-[4mm] py-[2.5mm] text-[9.5px]' : 'w-[80mm] px-[5mm] py-[4mm] text-[11px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-base font-bold tracking-widest">INVOICE</p>
|
||||||
|
{status && <p className="font-bold">*** {status} ***</p>}
|
||||||
|
</div>
|
||||||
|
{rule}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p>No: {invoiceNo(invoice.id)}</p>
|
||||||
|
<p>Date: {fmtDate(invoice.createdAt)}</p>
|
||||||
|
<p>Customer: {invoice.storeName}</p>
|
||||||
|
{customer?.phone && <p>Phone: {customer.phone}</p>}
|
||||||
|
</div>
|
||||||
|
{rule}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
|
||||||
|
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
|
||||||
|
<p>{i.itemName}</p>
|
||||||
|
<p className="flex justify-between">
|
||||||
|
{i.isFree ? (
|
||||||
|
<>
|
||||||
|
<span>{fmtQty(i.quantity)} FREE{i.schemeName ? ` (${i.schemeName})` : ''}</span>
|
||||||
|
<span>{num(0)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
|
||||||
|
<span>{num(i.quantity * i.unitPrice)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rule}
|
||||||
|
<p className="flex justify-between">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{fmtMoney(subtotal)}</span>
|
||||||
|
</p>
|
||||||
|
{discount > 0 && (
|
||||||
|
<p className="flex justify-between">
|
||||||
|
<span>Scheme discount</span>
|
||||||
|
<span>-{fmtMoney(discount)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
|
||||||
|
<p className="flex justify-between">
|
||||||
|
<span>TOTAL</span>
|
||||||
|
<span>{fmtMoney(total)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{paid > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="flex justify-between"><span>Paid</span><span>{fmtMoney(paid)}</span></p>
|
||||||
|
<p className="flex justify-between font-bold"><span>Balance</span><span>{fmtMoney(balance)}</span></p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<>
|
||||||
|
{rule}
|
||||||
|
{payments.map((p) => (
|
||||||
|
<p key={p.id} className="flex justify-between">
|
||||||
|
<span>{fmtDate(p.collectedAt)} {p.method}</span>
|
||||||
|
<span>{num(p.amount)}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showAgentEdits && (
|
||||||
|
<>
|
||||||
|
{rule}
|
||||||
|
<p>Notes:</p>
|
||||||
|
{[0, 1, 2].map((n) => <div key={n} className="h-5 border-b border-dotted border-black" />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rule}
|
||||||
|
<p className="text-center">Thank you!</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { invoices as invoicesApi, payments as paymentsApi, shopPos as shopPosApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { PaymentResponse, InvoiceResponse } from '@/types/api'
|
||||||
|
import type { InvoiceCustomer } from './invoice-template'
|
||||||
|
|
||||||
|
// Loads everything the printed invoice needs. Only the invoice is required — if payments or the
|
||||||
|
// customer details fail to load, that block is simply left out.
|
||||||
|
export function useInvoiceDocument(invoiceId: string | null | undefined, enabled = true) {
|
||||||
|
const [invoice, setInvoice] = useState<InvoiceResponse | null>(null)
|
||||||
|
const [payments, setPayments] = useState<PaymentResponse[]>([])
|
||||||
|
const [customer, setCustomer] = useState<InvoiceCustomer | undefined>()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !invoiceId) return
|
||||||
|
let cancelled = false
|
||||||
|
setInvoice(null)
|
||||||
|
setPayments([])
|
||||||
|
setCustomer(undefined)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
invoicesApi.get(invoiceId)
|
||||||
|
.then((inv) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setInvoice(inv)
|
||||||
|
paymentsApi.listByInvoice(inv.id).then((p) => !cancelled && setPayments(p)).catch(() => {})
|
||||||
|
shopPosApi.get(inv.shopPOId)
|
||||||
|
.then((po) => !cancelled && setCustomer({ address: po.storeAddress, phone: po.storeContactNumber }))
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
.catch((err) => !cancelled && setError(getApiError(err)))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [invoiceId, enabled])
|
||||||
|
|
||||||
|
return { invoice, payments, customer, error }
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Bell } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { useNotifications } from '@/contexts/notification-context'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
||||||
|
|
||||||
|
const SEVERITY_DOT: Record<NotificationSeverity, string> = {
|
||||||
|
Info: 'bg-sky-500',
|
||||||
|
Success: 'bg-emerald-500',
|
||||||
|
Warning: 'bg-amber-500',
|
||||||
|
Critical: 'bg-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo(iso: string): string {
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
||||||
|
if (seconds < 60) return 'just now'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
return new Date(iso).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationRow({
|
||||||
|
notification: n,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
notification: NotificationResponse
|
||||||
|
onOpen: (n: NotificationResponse) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(n)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
|
||||||
|
!n.isRead && 'bg-muted/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
|
||||||
|
{n.title}
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const { recent, unreadCount, open, markAllRead } = useNotifications()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell size={16} />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<span className="text-sm font-medium">Notifications</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
onClick={() => { void markAllRead() }}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 divide-y divide-border overflow-y-auto">
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You're all caught up.</p>
|
||||||
|
) : (
|
||||||
|
recent.map(n => (
|
||||||
|
<NotificationRow
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onOpen={item => { setIsOpen(false); void open(item) }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
align = "end",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 6,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
|
||||||
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
|
import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
||||||
|
|
||||||
|
const RECENT_COUNT = 20
|
||||||
|
|
||||||
|
const TOAST_TYPE: Record<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
|
||||||
|
Info: 'info',
|
||||||
|
Success: 'success',
|
||||||
|
Warning: 'warning',
|
||||||
|
Critical: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationContextValue {
|
||||||
|
/** The most recent notifications, newest first (what the bell popover shows). */
|
||||||
|
recent: NotificationResponse[]
|
||||||
|
unreadCount: number
|
||||||
|
/** Marks the notification read (if needed) and navigates to its link. */
|
||||||
|
open: (n: NotificationResponse) => Promise<void>
|
||||||
|
markAllRead: () => Promise<void>
|
||||||
|
/** Re-fetches the recent list and the unread count. */
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [recent, setRecent] = useState<NotificationResponse[]>([])
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [page, count] = await Promise.all([
|
||||||
|
notifications.list({ page: 1, pageSize: RECENT_COUNT }),
|
||||||
|
notifications.unreadCount(),
|
||||||
|
])
|
||||||
|
setRecent(page.items)
|
||||||
|
setUnreadCount(count)
|
||||||
|
} catch {
|
||||||
|
// The bell is a convenience; never break the page if it can't load.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markRead = useCallback(async (n: NotificationResponse) => {
|
||||||
|
if (n.isRead) return
|
||||||
|
setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
||||||
|
setUnreadCount(c => Math.max(0, c - 1))
|
||||||
|
try {
|
||||||
|
await notifications.markRead(n.id)
|
||||||
|
} catch {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const open = useCallback(async (n: NotificationResponse) => {
|
||||||
|
await markRead(n)
|
||||||
|
if (n.link) router.push(n.link)
|
||||||
|
}, [markRead, router])
|
||||||
|
|
||||||
|
const markAllRead = useCallback(async () => {
|
||||||
|
setRecent(prev => prev.map(x => ({ ...x, isRead: true })))
|
||||||
|
setUnreadCount(0)
|
||||||
|
try {
|
||||||
|
await notifications.markAllRead()
|
||||||
|
} catch {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
// Keep the latest `open` for the long-lived socket handler without reconnecting.
|
||||||
|
const openRef = useRef(open)
|
||||||
|
useEffect(() => { openRef.current = open }, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
void refresh()
|
||||||
|
|
||||||
|
const connection = new HubConnectionBuilder()
|
||||||
|
.withUrl(NOTIFICATION_HUB_URL, { withCredentials: true })
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Warning)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
connection.on('notification', (n: NotificationResponse) => {
|
||||||
|
setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT))
|
||||||
|
setUnreadCount(c => c + 1)
|
||||||
|
toast.add({
|
||||||
|
title: n.title,
|
||||||
|
description: n.message,
|
||||||
|
type: TOAST_TYPE[n.severity] ?? 'info',
|
||||||
|
actionProps: n.link
|
||||||
|
? { children: 'Open', onClick: () => { void openRef.current(n) } }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Anything pushed while the socket was down is only in the inbox — catch up.
|
||||||
|
connection.onreconnected(() => { void refresh() })
|
||||||
|
|
||||||
|
connection.start().catch(() => {
|
||||||
|
// Initial connect failed (offline / API down); the inbox endpoints still work.
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { void connection.stop() }
|
||||||
|
}, [user, refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider
|
||||||
|
value={{
|
||||||
|
recent: user ? recent : [],
|
||||||
|
unreadCount: user ? unreadCount : 0,
|
||||||
|
open,
|
||||||
|
markAllRead,
|
||||||
|
refresh,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(): NotificationContextValue {
|
||||||
|
const ctx = useContext(NotificationContext)
|
||||||
|
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -11,11 +11,12 @@ export { stock } from './stock'
|
|||||||
export { productReports, stockReports, dashboardReports } from './reports'
|
export { productReports, stockReports, dashboardReports } from './reports'
|
||||||
export { shopPos, invoices, payments } from './sales'
|
export { shopPos, invoices, payments } from './sales'
|
||||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
|
||||||
export { bankAccounts } from './bank-accounts'
|
export { bankAccounts } from './bank-accounts'
|
||||||
export { chequeBooks, chequePages } from './cheques'
|
export { chequeBooks, chequePages } from './cheques'
|
||||||
export { receivedCheques } from './received-cheques'
|
export { receivedCheques } from './received-cheques'
|
||||||
export { supplierPayments } from './supplier-payments'
|
export { supplierPayments } from './supplier-payments'
|
||||||
|
export { notifications } from './notifications'
|
||||||
export { getPaginatedItems } from './paginated'
|
export { getPaginatedItems } from './paginated'
|
||||||
|
|
||||||
export type { PageParams } from './master'
|
export type { PageParams } from './master'
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { NotificationPage } from '@/types/notifications'
|
||||||
|
|
||||||
|
export const notifications = {
|
||||||
|
list: (params?: { unreadOnly?: boolean; page?: number; pageSize?: number }) =>
|
||||||
|
api.get<NotificationPage>('/notifications', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
unreadCount: () =>
|
||||||
|
api.get<{ count: number }>('/notifications/unread-count').then(r => r.data.count),
|
||||||
|
|
||||||
|
markRead: (id: string) =>
|
||||||
|
api.patch(`/notifications/${id}/read`).then(() => undefined),
|
||||||
|
|
||||||
|
markAllRead: () =>
|
||||||
|
api.patch('/notifications/read-all').then(() => undefined),
|
||||||
|
|
||||||
|
remove: (id: string) =>
|
||||||
|
api.delete(`/notifications/${id}`).then(() => undefined),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SignalR hub URL — the API base URL without its trailing /api. */
|
||||||
|
export const NOTIFICATION_HUB_URL =
|
||||||
|
(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5089/api').replace(/\/api\/?$/, '') +
|
||||||
|
'/hubs/notifications'
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
PriceResolution,
|
PriceResolution,
|
||||||
SchemeResponse,
|
SchemeResponse,
|
||||||
SchemeRuleType,
|
SchemeRuleType,
|
||||||
|
SchemeSuggestion,
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
// ─── Price Lists ──────────────────────────────────────────────────────────────
|
// ─── Price Lists ──────────────────────────────────────────────────────────────
|
||||||
@@ -89,4 +90,17 @@ 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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const productReports = {
|
|||||||
export interface SalesKpis {
|
export interface SalesKpis {
|
||||||
grossSales: number
|
grossSales: number
|
||||||
returnValue: number
|
returnValue: number
|
||||||
|
discounts: number
|
||||||
netSales: number
|
netSales: number
|
||||||
orders: number
|
orders: number
|
||||||
averageOrderValue: number
|
averageOrderValue: number
|
||||||
@@ -170,11 +171,19 @@ export interface SalesKpis {
|
|||||||
grossProfitMarginPercent: number | null
|
grossProfitMarginPercent: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CashKpis {
|
||||||
|
collected: number
|
||||||
|
outstanding: number
|
||||||
|
overdue: number
|
||||||
|
collectionRatePercent: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSalesSummary {
|
export interface DashboardSalesSummary {
|
||||||
from: string
|
from: string
|
||||||
to: string
|
to: string
|
||||||
granularity: 'day' | 'month'
|
granularity: 'day' | 'month'
|
||||||
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
|
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
|
||||||
|
cash: { current: CashKpis; previous: CashKpis }
|
||||||
trend: Array<{ period: string; sales: number; returns: number }>
|
trend: Array<{ period: string; sales: number; returns: number }>
|
||||||
byCategory: Array<{ id: string | null; name: string; amount: number }>
|
byCategory: Array<{ id: string | null; name: string; amount: number }>
|
||||||
byRegion: Array<{ id: string | null; name: string; amount: number }>
|
byRegion: Array<{ id: string | null; name: string; amount: number }>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user