Compare commits

...

27 Commits

Author SHA1 Message Date
DeepnaPooja 2c39b34c5f feat: add discount amount to invoice response and display in invoice view 2026-10-22 10:44:18 +05:30
DeepnaPooja 99832df134 feat: add interactive sales trend and region sales pie components to dashboard 2026-10-22 10:43:39 +05:30
DeepnaPooja 2be9d9b396 feat: add DiscountAmount property to Invoice model for discount calculations 2026-10-22 10:41:58 +05:30
DeepnaPooja a12e170205 Add DiscountAmount column to Invoices table 2026-10-22 10:41:20 +05:30
DeepnaPooja fdfca7cf5e feat: add HasLockedInvoiceAsync method to check invoice status and prevent scheme application 2026-10-22 10:40:43 +05:30
DeepnaPooja e84ce29064 feat: update SoldLines method to use InvoiceItem and improve sales calculations 2026-10-22 10:40:09 +05:30
DeepnaPooja 2be2928ad1 feat: add scheme discount calculation to invoice processing 2026-10-22 10:39:01 +05:30
DeepnaPooja 872fd42c4a feat: enhance dashboard report with receivables and discounts calculations 2026-10-22 10:37:56 +05:30
DeepnaPooja d79c5e55d0 Update doc for Shop PO to Invoice operations flow 2026-10-21 13:06:06 +05:30
DeepnaPooja 50d3113c28 Update Po invoice flow.md file 2026-10-21 13:04:44 +05:30
DeepnaPooja 34d33571de feat: enhance invoice item updates 2026-10-21 13:01:23 +05:30
DeepnaPooja 0f575370d4 Add migrations 2026-10-21 13:00:30 +05:30
DeepnaPooja e4061fa94c feat: implement invoice formatting and document loading hooks 2026-10-21 12:55:29 +05:30
DeepnaPooja 5f8bc70174 feat: add standalone printable invoice page with paper size selection 2026-10-21 12:54:51 +05:30
DeepnaPooja f2d5c9f50a fix invoice action dialog and order view with editing and printing capabilities 2026-10-21 12:51:02 +05:30
DeepnaPooja 9e39981c9a Fix invoice editing 2026-10-21 12:49:02 +05:30
DeepnaPooja bd8b2d8b44 Replace toggle group with invoice configuration 2026-10-21 12:47:59 +05:30
DeepnaPooja f26557a66e Enhance invoice processing by adding items and versioning 2026-10-21 12:46:56 +05:30
DeepnaPooja 91959913a8 feat: add invoice versioning models and enums 2026-10-21 12:44:58 +05:30
DeepnaPooja 48f607d623 Invoice handling with versioning and item updates 2026-10-21 12:41:20 +05:30
DeepnaPooja 82be251878 Merge dev into feat-notification
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m21s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m5s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-20 17:19:51 +05:30
DeepnaPooja 209ee7da2d fe integration for notification system with context, components, and API integration 2026-10-20 16:53:15 +05:30
DeepnaPooja 6b9f27573b Introduced NotificationService for managing notification logic and recipient resolution.
- Integrated SignalR for real-time notification delivery to users.
2026-10-20 16:51:01 +05:30
DeepnaPooja a0df2a1bbf feat: integrate notification system across PO,store,supplier claims,delivery task and ride 2026-10-20 16:49:05 +05:30
DeepnaPooja d1c3d4e22f feat: implement notification system with repository, service, and controller layers 2026-10-20 16:45:48 +05:30
Kalana B. Thilakarathna a80512994b Add invoice creation mode support to Shop PO flow
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m8s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m56s
Introduce invoice creation mode feature using invoiceCreationModeApi and DEFAULT_INVOICE_CREATION_MODE. Update confirmation logic to call confirmAndCreateInvoice and show draft invoice messaging in ModeA. Update types and UI in page.tsx and confirm-shop-po-dialog.tsx for consistent behavior.
2026-10-20 14:02:11 +05:30
Kalana B. Thilakarathna 2de9e5e4ad Add manual invoice creation mode with new API & UI flow
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m13s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m28s
Introduced "ModeB" for manual invoice creation in system config. Added backend endpoints and service logic for creating invoices from confirmed Shop POs, with validation and error handling. Updated DTOs to support optional CreatedByUserId. On the frontend, added configuration card to toggle modes, a dialog for manual invoice creation, and UI updates to support the new workflow. Enables admins to choose between automatic and manual invoice creation.
2026-10-20 13:38:43 +05:30
84 changed files with 29171 additions and 348 deletions
@@ -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);
}
}
+77
View File
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { 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<DeliveryRun> DeliveryRuns { 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<SupplierPayment> SupplierPayments { 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; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -173,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ShopPOId)
.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>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
@@ -708,6 +761,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierPaymentGRN>()
.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 ---
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 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(
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);
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 NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
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);
public record PeriodAmount(DateTime Date, decimal Amount);
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// 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)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
from li in db.InvoiceItems.AsNoTracking()
where li.IsActive && !li.IsRemoved
&& li.Invoice.Status == InvoiceStatus.Finalized
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
select new SoldLine
{
Date = inv.FinalizedAt!.Value.Date,
PoId = li.FK_ShopPOId,
Value = li.QuantityFulfilled * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.Invoice.FK_ShopPOId,
Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name,
RepId = li.ShopPO.RaisedById,
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.Invoice.ShopPO.RaisedById,
};
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);
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
{
var start = f.From.Date;
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()
.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.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q;
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 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) =>
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo)
{
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)
{
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
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;
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,
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)
{
var days = (f.To - f.From).Days + 1;
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
var current = await repo.GetSalesTotalsAsync(f);
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 returns = await repo.GetDailyReturnsAsync(f);
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month",
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,
(await repo.GetSalesByCategoryAsync(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
{
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
@@ -1,4 +1,5 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo,
StockMover mover,
INotificationService notifications,
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) =>
new(t.Id,
t.FK_RideId,
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
}
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);
return ToResponse(saved!);
}
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
}
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);
}
}
@@ -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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
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);
}
@@ -36,6 +36,45 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
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 }); }
}
[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")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
+30 -3
View File
@@ -2,11 +2,36 @@
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = 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 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);
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null);
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);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
@@ -20,8 +45,10 @@ public record InvoiceResponse(
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt);
DateTime CreatedAt,
List<InvoiceItemResponse> Items);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.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))
.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)
.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) =>
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) =>
context.Invoices.Update(invoice);
}
+215 -18
View File
@@ -1,4 +1,5 @@
using Distribution.Features.Stock;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -7,6 +8,7 @@ namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IShopPORepository shopPoRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
@@ -17,9 +19,72 @@ public class InvoiceService(
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount,
i.TotalAmount, i.DiscountAmount,
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)).ToList());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{
var billable = BillableTotal(items);
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
invoice.TotalAmount = billable - invoice.DiscountAmount;
}
// 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
}).ToList();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
@@ -42,6 +107,123 @@ public class InvoiceService(
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) 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)");
}
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
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 ToResponse((await repo.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)
{
var invoice = await repo.GetByIdAsync(id)
@@ -50,12 +232,21 @@ public class InvoiceService(
if (invoice.Status != InvoiceStatus.Draft)
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.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy;
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; }
return ToResponse(invoice);
@@ -69,31 +260,37 @@ public class InvoiceService(
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var items = invoice.Items.Where(i => i.IsActive).ToList();
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();
decimal total = 0;
foreach (var item in poItems)
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
item.IsRemoved = adjustment.IsRemoved;
if (!adjustment.IsRemoved)
{
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
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;
ValidateQuantity(item, adjustment.AdjustedQuantity);
item.Quantity = adjustment.AdjustedQuantity;
}
invoice.TotalAmount = total;
}
if (items.Count > 0)
await ApplyTotalsAsync(invoice, items);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
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; }
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);
}
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
{
var lines =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
from li in db.InvoiceItems.AsNoTracking()
where li.IsActive && !li.IsRemoved
&& li.Invoice.Status == InvoiceStatus.Finalized
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
select li;
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.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.RouteId.HasValue) lines = lines.Where(l => l.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.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.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.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
.Select(g => new
{
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.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
});
var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.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.
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))
.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()
.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,
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);
}
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)
{
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(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);
}
+51 -1
View File
@@ -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.DriverProfile;
using Distribution.Infra.UoW;
@@ -10,9 +12,14 @@ public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IDeliveryTaskRepository taskRepo,
StockMover mover,
INotificationService notifications,
ILogger<RideService> logger,
IUnitOfWork uow)
{
private const string RidesLink = "/operations/rides";
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
{
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
@@ -72,6 +79,12 @@ public class RideService(
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
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);
return ToResponse(saved!);
}
@@ -125,9 +138,46 @@ public class RideService(
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyRideCancelledAsync(ride, request, userId, ct);
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)
{
var driver = await driverRepo.GetByIdAsync(driverProfileId)
@@ -11,6 +11,7 @@ public interface ISchemeRepository
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity);
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
@@ -52,6 +52,10 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid 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) =>
await context.SchemeBundleComponents
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
@@ -383,6 +383,9 @@ public class SchemeService(
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
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 eligibility = EvaluateRule(scheme, items);
if (!eligibility.Eligible)
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
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]
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
{
@@ -1,4 +1,5 @@
using Distribution.Features.Invoice;
using Distribution.Features.Notifications;
using Distribution.Features.PriceList;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
@@ -10,8 +11,17 @@ public class ShopPOService(
IShopPORepository repo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
INotificationService notifications,
IUnitOfWork uow)
{
// 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) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber,
@@ -55,6 +65,13 @@ public class ShopPOService(
return ToResponse(po, users);
}
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)
{
if (request.Items.Count == 0)
@@ -168,6 +185,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
return await ToResponseWithUserAsync(po);
}
@@ -198,12 +217,17 @@ public class ShopPOService(
try
{
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;
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
@@ -231,6 +255,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
return await ToResponseWithUserAsync(po);
}
}
+15 -1
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Notifications;
using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Features.StoreCredit;
@@ -7,7 +8,7 @@ using Distribution.Models;
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 CreditApproverRoleLevel = 20;
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct);
try
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
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);
}
@@ -1,4 +1,5 @@
using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,6 +10,7 @@ public class SupplierClaimService(
ISupplierClaimRepository repo,
IPoRepository poRepo,
IGrnRepository grnRepo,
INotificationService notifications,
IUnitOfWork uow)
{
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
@@ -149,9 +151,19 @@ public class SupplierClaimService(
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
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)
{
var claim = await repo.GetByIdAsync(id)
@@ -172,6 +184,8 @@ public class SupplierClaimService(
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
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");
}
}
}
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
+1
View File
@@ -18,6 +18,7 @@ namespace Distribution.Models
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum InvoiceStatus { Draft, Printed, Finalized }
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
+5
View File
@@ -19,8 +19,13 @@ namespace Distribution.Models
public DateTime? FinalizedAt { 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 ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
}
}
+23
View File
@@ -0,0 +1,23 @@
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; }
}
}
+19
View File
@@ -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>();
}
}
+23
View File
@@ -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; }
}
}
+33
View File
@@ -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; }
}
}
+11
View File
@@ -12,6 +12,7 @@ using Distribution.Features.ReceivedCheque;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Invoice;
@@ -142,6 +143,15 @@ builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>();
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.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -193,5 +203,6 @@ app.UseMiddleware<AuthMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<NotificationHub>(NotificationHub.Path);
app.Run();
+297 -34
View File
@@ -1,67 +1,330 @@
# 21 — Notifications & Alerts
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
**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, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
---
## Goal
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
produce.
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
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
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
[12_store_management.md](./12_store_management.md), route deviation logic in
[09_fraud_controls.md](./09_fraud_controls.md)).
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|---|---|---|---|---|---|---|---|
| 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
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
```
Service action ──► uow.CommitAsync() (business transaction, unchanged)
│
▼ 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) |
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
| `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)) |
| `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. |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
| Channels | In-app only (SignalR). Email / SMS / push deferred |
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
| 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
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
transaction that triggered them — fire-and-forget, logged separately.
**Notification** — one row per event
| Field | Type | Notes |
|---|---|---|
| `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
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)).
Resolved — see **Decisions**. Remaining for later:
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,89 @@ Invoice
├── 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)
└── 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.
- Not included (no data in the app): company/"From" block, logo, bank details, discount, tax, due date.
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
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`
```csharp
@@ -140,6 +223,7 @@ Draft → Printed → Finalized
|---|---|---|
| (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. |
| 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. |
| 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 +246,10 @@ Draft → Printed → Finalized
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.
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
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.
@@ -201,8 +288,9 @@ Draft → Printed → Finalized
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
also unresolved. Both cases need explicit cascade rules before implementation.
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
---
@@ -35,10 +35,28 @@ status transition and stock movement along the way.
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
`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`.
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
quantity) and recomputes `TotalAmount` from the adjusted lines.
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
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`:
`Pending → PartiallyPaid → Paid`.
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
import { RegionSalesPie } from './region-sales-pie'
import { SalesTrendInteractive } from './sales-trend-interactive'
export interface DateRange {
from: string // yyyy-MM-dd
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
function LoadingGrid() {
return (
<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">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
if (loading || productsLoading || !sales) return <LoadingGrid />
const { current: c, previous: p } = sales.kpis
const { current: cash, previous: cashPrev } = sales.cash
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
<Kpi
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 regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
return (
<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('Gross Sales', 'grossSales')}
{kpi('Total Orders', 'orders', false)}
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
}
/>
{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>
<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.
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
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>
<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">
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
</ChartCard>
<SalesTrendInteractive className="md:col-span-2" />
<ChartCard title="Sales by Category" note="Share of gross sales">
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
</ChartCard>
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
])}
/>
</ChartCard>
<ChartCard title="Sales by Region">
<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>
<RegionSalesPie rows={sales.byRegion} />
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
<MultiAreaChart
@@ -18,7 +18,7 @@ import {
getPaginatedItems,
} from '@/lib/api/index'
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 { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } 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: '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: '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: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
]
@@ -153,7 +152,6 @@ interface DashboardData {
confirmedSheetsCount: number
pendingTasksCount: number
partialTasksCount: number
lowStock: LowStockItemResponse[]
movements: StockMovementResponse[]
warehouseBalances: WarehouseBalanceRow[]
outstandingAmount: number
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
openRides: [],
plannedSheetsCount: 0, confirmedSheetsCount: 0,
pendingTasksCount: 0, partialTasksCount: 0,
lowStock: [], movements: [], warehouseBalances: [],
movements: [], warehouseBalances: [],
outstandingAmount: 0, outstandingInvoiceCount: 0,
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
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')) {
fetches.push(
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>
)
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':
return data.movements.length === 0 ? (
<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>
)
}
@@ -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 { GrnTaxCard } from './_components/grn-tax-card'
import { InvoiceCreationModeCard } from './_components/invoice-creation-mode-card'
export default function ConfigurationPage() {
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>
</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="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<ProductCapabilitiesCard />
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<GrnTaxCard />
</div>
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<InvoiceCreationModeCard />
</div>
</div>
</div>
)
+21 -16
View File
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
import { AppSidebar } from '@/components/layout/app-sidebar'
import { BottomNav } from '@/components/layout/bottom-nav'
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'
export default function DashboardLayout({
@@ -10,21 +12,24 @@ export default function DashboardLayout({
children: React.ReactNode
}) {
return (
<SidebarProvider>
<TopLoader />
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
</main>
</SidebarInset>
<BottomNav />
</SidebarProvider>
<NotificationProvider>
<SidebarProvider>
<TopLoader />
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto flex items-center gap-1">
<NotificationBell />
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
</main>
</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 />
}
@@ -24,9 +24,12 @@ import {
items as itemsApi,
priceLists as priceListsApi,
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 { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
const lineItemSchema = z.object({
@@ -56,6 +59,11 @@ export default function EditShopPOPage() {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [confirmedByUserId, setConfirmedByUserId] = useState('')
const [confirming, setConfirming] = useState(false)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -98,9 +106,14 @@ export default function EditShopPOPage() {
if (!po || !confirmedByUserId) return
setConfirming(true)
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)
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) {
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
} finally {
@@ -21,7 +21,15 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
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'
const schema = z.object({
@@ -39,6 +47,7 @@ interface ConfirmShopPODialogProps {
export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -51,14 +60,20 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [open])
if (!po) return null
async function onSubmit(values: FormValues) {
try {
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
toast.add({ title: 'Shop PO confirmed', type: 'success' })
if (mode === 'ModeA') {
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()
onClose()
} catch (err) {
@@ -71,7 +86,10 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
<DialogContent className="sm:max-w-md">
<DialogHeader>
<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>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -0,0 +1,342 @@
'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,
priceLists as priceListsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, 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)
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)
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,
}))
const items = [...existing, ...extra]
const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
return { ...full, items, totalAmount }
}, [full, qty, price, removed, added])
// Items not already on the invoice (a deleted line can be restored with Undo instead).
const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id))
// 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,
)
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>
) : (
<>
<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) => (
<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 || (!dirty && !pendingLine())}>
{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 { toast } from '@/components/ui/toast'
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 { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
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'> = {
Draft: 'secondary',
Printed: 'outline',
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
const [actionOpen, setActionOpen] = useState(false)
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
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)
setVersions(vers)
return inv
}, [params.id])
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
</div>
<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' && (
<Button type="button" onClick={handlePrint}>
Print Invoice
</Button>
)}
{invoice.status !== 'Draft' && (
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
Reprint
</Button>
)}
{invoice.status === 'Printed' && (
<Button type="button" onClick={handleFinalize}>
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">
<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="Balance" value={fmtAmount(balance)} />
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
@@ -167,6 +187,84 @@ export default function InvoiceViewPage() {
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
</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-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={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
</tr>
) : (
invoice.items.map((i) => (
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
<td className="px-3 py-2">
{i.itemName}
{i.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.orderedQuantity ?? '—'}</td>
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.quantity}
</td>
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td>
</tr>
))
)}
</tbody>
</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="mb-4">
<h3 className="text-sm font-medium text-foreground">Payments</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&apos;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>
)
}
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
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 type { InvoiceResponse, UserResponse } from '@/types/api'
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
}
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' },
}
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
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>({
resolver: zodResolver(schema),
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
useEffect(() => {
if (!open) return
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()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
@@ -62,21 +79,35 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
if (!invoice) return null
const lines = full?.items ?? []
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 {
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
else await invoicesApi.finalize(invoice!.id, values.userId)
if (mode === 'print') {
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' })
onSuccess()
onClose()
} catch (err) {
printTab?.close()
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
/>
</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="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}>
{removed[i.id] ? 'Undo' : 'Remove'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
@@ -1,35 +1,15 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
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',
})
: '—'
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
import type { InvoiceResponse } from '@/types/api'
interface OrderViewSheetProps {
open: boolean
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
invoice: InvoiceResponse | 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>
)
}
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
const doc = useInvoiceDocument(invoice?.id, open)
if (!invoice) return null
return (
<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>
<DialogTitle>Invoice Details</DialogTitle>
<DialogTitle>Invoice Preview</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
<Row label="Store" value={invoice.storeName} />
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
</div>
{doc.invoice ? (
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
</p>
)}
{doc.invoice && doc.invoice.status !== 'Draft' && (
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
Open print view
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
@@ -1,16 +1,20 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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 { OrderViewSheet } from './order-view-sheet'
import { InvoiceActionDialog } from './invoice-action-dialog'
import { CreateInvoiceDialog } from './create-invoice-dialog'
import { PaymentSheet } from '../../payments/_components/payment-sheet'
const fmtDate = (v: string) =>
@@ -32,6 +36,7 @@ const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'd
}
export function OrdersClient() {
const router = useRouter()
const [data, setData] = useState<InvoiceResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -44,6 +49,12 @@ export function OrdersClient() {
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const [paymentOpen, setPaymentOpen] = useState(false)
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) => {
setLoading(true)
@@ -67,6 +78,10 @@ export function OrdersClient() {
setViewOpen(true)
}
function handleEdit(row: InvoiceResponse) {
router.push(`/sales/invoices/${row.id}/edit`)
}
function handlePrint(row: InvoiceResponse) {
setSelectedInvoice(row)
setActionMode('print')
@@ -147,12 +162,24 @@ export function OrdersClient() {
label: 'View invoice',
onClick: handleView,
},
{
action: 'edit',
label: 'Edit draft items',
onClick: handleEdit,
visible: (target) => target.status === 'Draft',
},
{
action: 'print',
label: 'Mark as printed',
label: 'Print invoice',
onClick: handlePrint,
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',
label: 'Finalize invoice',
@@ -178,6 +205,9 @@ export function OrdersClient() {
Billing documents generated from confirmed Shop POs
</p>
</div>
{mode === 'ModeB' && (
<Button onClick={() => setCreateOpen(true)}>Create Invoice</Button>
)}
</div>
<DataTable
@@ -212,6 +242,12 @@ export function OrdersClient() {
invoices={paymentInvoice ? [paymentInvoice] : []}
defaultInvoiceId={paymentInvoice?.id}
/>
<CreateInvoiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -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,304 @@
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 total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
const paid = payments.reduce((s, p) => s + p.amount, 0)
return { 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 { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice)
const hasRemoved = invoice.items.some((i) => i.isRemoved)
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>
<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>
) : (
invoice.items.map((item) => (
<tr
key={item.id}
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
>
<td className="px-2 py-2.5">{item.itemName}</td>
<td className="px-2 py-2.5 text-right">
{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(total)}</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 { 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.map((i) => (
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
<p>{i.itemName}</p>
<p className="flex justify-between">
<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(total)}</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&apos;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>
)
}
+50
View File
@@ -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
}
+2 -1
View File
@@ -11,11 +11,12 @@ export { stock } from './stock'
export { productReports, stockReports, dashboardReports } from './reports'
export { shopPos, invoices, payments } from './sales'
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 { chequeBooks, chequePages } from './cheques'
export { receivedCheques } from './received-cheques'
export { supplierPayments } from './supplier-payments'
export { notifications } from './notifications'
export { getPaginatedItems } from './paginated'
export type { PageParams } from './master'
+24
View File
@@ -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'
+9
View File
@@ -163,6 +163,7 @@ export const productReports = {
export interface SalesKpis {
grossSales: number
returnValue: number
discounts: number
netSales: number
orders: number
averageOrderValue: number
@@ -170,11 +171,19 @@ export interface SalesKpis {
grossProfitMarginPercent: number | null
}
export interface CashKpis {
collected: number
outstanding: number
overdue: number
collectionRatePercent: number | null
}
export interface DashboardSalesSummary {
from: string
to: string
granularity: 'day' | 'month'
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
cash: { current: CashKpis; previous: CashKpis }
trend: Array<{ period: string; sales: number; returns: number }>
byCategory: Array<{ id: string | null; name: string; amount: number }>
byRegion: Array<{ id: string | null; name: string; amount: number }>
+15 -2
View File
@@ -4,6 +4,7 @@ import type {
ShopPOResponse,
InvoiceResponse,
InvoiceStatus,
InvoiceVersionResponse,
PaymentStatus,
PaymentResponse,
PaymentMethod,
@@ -48,6 +49,9 @@ export const shopPos = {
get: (id: string) =>
api.get<ShopPOResponse>(`/shop-pos/${id}`).then(r => r.data),
confirmedWithoutInvoice: (params?: { agentId?: string; routeId?: string; shopId?: string }) =>
api.get<ShopPOResponse[]>('/shop-pos/confirmed-without-invoice', { params }).then(r => r.data),
create: (body: CreateShopPOBody) =>
api.post<ShopPOResponse>('/shop-pos', body).then(r => r.data),
@@ -84,11 +88,20 @@ export const invoices = {
get: (id: string) =>
api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
create: (shopPOId: string, createdByUserId?: string | null) =>
api.post<InvoiceResponse>('/invoices', { shopPOId, createdByUserId }).then(r => r.data),
print: (id: string, printedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
finalize: (id: string, finalizedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId }).then(r => r.data),
finalize: (id: string, finalizedByUserId?: string | null, adjustments?: { invoiceItemId: string; adjustedQuantity: number; isRemoved?: boolean }[]) =>
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId, adjustments }).then(r => r.data),
updateItems: (id: string, items: { invoiceItemId?: string; itemId?: string; quantity: number; unitPrice?: number | null; isRemoved?: boolean }[], editedByUserId?: string | null) =>
api.put<InvoiceResponse>(`/invoices/${id}/items`, { items, editedByUserId }).then(r => r.data),
versions: (id: string) =>
api.get<InvoiceVersionResponse[]>(`/invoices/${id}/versions`).then(r => r.data),
}
// ─── Payments ─────────────────────────────────────────────────────────────────
+16
View File
@@ -72,3 +72,19 @@ export const grnTaxSettings = {
.getAll()
.then(all => parseGrnTaxSettings(Object.fromEntries(all.map(c => [c.key, c.value])))),
}
// ─── Invoice creation mode (must match Distribution.Features.SysConfig.SysConfigService) ─────
export const INVOICE_CREATION_MODE_KEY = 'InvoiceCreationMode'
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
export const DEFAULT_INVOICE_CREATION_MODE: InvoiceCreationMode = 'ModeB'
export const invoiceCreationMode = {
get: () =>
api.get<{ mode: InvoiceCreationMode }>('/sys-config/invoice-creation-mode').then(r => r.data.mode),
set: (mode: InvoiceCreationMode) =>
api.put('/sys-config/invoice-creation-mode', { mode }).then(r => r.data),
}
+181 -1
View File
@@ -12,6 +12,7 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
"@microsoft/signalr": "^10.0.11",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
@@ -1763,6 +1764,28 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@microsoft/signalr": {
"version": "10.0.11",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz",
"integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@microsoft/signalr/node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -3251,6 +3274,18 @@
"win32"
]
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5683,6 +5718,15 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -5923,6 +5967,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
@@ -8426,6 +8480,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@@ -9272,11 +9346,22 @@
"node": ">=10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -9298,6 +9383,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9651,6 +9742,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
@@ -9971,6 +10068,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10860,6 +10963,36 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/traverse": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.10.tgz",
@@ -11316,6 +11449,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -11391,6 +11534,22 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -11507,6 +11666,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
"@microsoft/signalr": "^10.0.11",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
+33
View File
@@ -12,6 +12,7 @@ export type PurchaseOrderStatus =
| 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled'
export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled'
export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
export type InvoiceVersionStage = 'Created' | 'DraftEdited' | 'Printed' | 'Finalized'
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
export type PaymentMethod = 'Cash' | 'Cheque' | 'Credit'
@@ -1001,6 +1002,36 @@ export interface ShopPOResponse {
cancelledByRole: string | null
}
export interface InvoiceItemResponse {
id: string
shopPOItemId: string | null
itemId: string
itemName: string
orderedQuantity: number | null
quantity: number
unitPrice: number
isRemoved: boolean
}
export interface InvoiceVersionItemResponse {
shopPOItemId: string | null
itemId: string
itemName: string
quantity: number
unitPrice: number
isRemoved: boolean
}
export interface InvoiceVersionResponse {
id: string
versionNumber: number
stage: InvoiceVersionStage
totalAmount: number
createdBy: string
createdAt: string
items: InvoiceVersionItemResponse[]
}
export interface InvoiceResponse {
id: string
shopPOId: string
@@ -1014,9 +1045,11 @@ export interface InvoiceResponse {
finalizedAt: string | null
finalizedBy: string | null
totalAmount: number
discountAmount: number
paidAmount: number
isActive: boolean
createdAt: string
items: InvoiceItemResponse[]
}
export interface OutstandingPaymentsResponse {
+23
View File
@@ -0,0 +1,23 @@
export type NotificationSeverity = 'Info' | 'Success' | 'Warning' | 'Critical'
export interface NotificationResponse {
/** The caller's own recipient row id — use it for read / delete. */
id: string
type: string
severity: NotificationSeverity
title: string
message: string
entityType: string
entityId: string
link: string
createdAt: string
isRead: boolean
readAt: string | null
}
export interface NotificationPage {
page: number
pageSize: number
total: number
items: NotificationResponse[]
}