Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5acbab76 | |||
| 29f181d5e1 |
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.Region;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Moq;
|
||||
@@ -11,14 +10,11 @@ public class RegionServiceTests
|
||||
{
|
||||
private readonly Mock<IRegionRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||
private readonly RegionService _sut;
|
||||
|
||||
public RegionServiceTests()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||
_sut = new RegionService(_repo.Object, _uow.Object);
|
||||
}
|
||||
|
||||
private static Region ActiveRegion(string name = "North") =>
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
using Distribution.Features.Region;
|
||||
using Distribution.Features.Route;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class RouteServiceTests
|
||||
{
|
||||
private readonly Mock<IRouteRepository> _repo = new();
|
||||
private readonly Mock<IRegionRepository> _regionRepo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||
private readonly RouteService _sut;
|
||||
|
||||
private static readonly Guid RepUserId = Guid.NewGuid();
|
||||
|
||||
public RouteServiceTests()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(20);
|
||||
_currentUser.Setup(u => u.UserId).Returns(RepUserId);
|
||||
_sut = new RouteService(_repo.Object, _regionRepo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||
}
|
||||
|
||||
private static Models.Route ActiveRoute(Guid? ownerId = null) =>
|
||||
new() { Id = Guid.NewGuid(), RouteName = "R1", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = true, FK_OwnerUserId = ownerId };
|
||||
|
||||
private static Models.Route InactiveRoute() =>
|
||||
new() { Id = Guid.NewGuid(), RouteName = "Old", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = false };
|
||||
|
||||
private static Store ActiveStore(Guid routeId, decimal lat = 10m, decimal lon = 20m) =>
|
||||
new() { Id = Guid.NewGuid(), FK_RouteId = routeId, IsActive = true, Latitude = lat, Longitude = lon };
|
||||
|
||||
// AssignStoresToRouteAsync
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteNotFound()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||
|
||||
Assert.Null(result);
|
||||
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteInactive()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||
|
||||
Assert.Null(result);
|
||||
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerBelowRoleFloor()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(10);
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenAnyStoreIdMissing()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
var storeIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
|
||||
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync([ActiveStore(route.Id)]); // only one of two found
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
||||
|
||||
Assert.Null(result);
|
||||
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_UpdatesEachStore_AndCommits()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var otherRouteId = Guid.NewGuid();
|
||||
var stores = new List<Store> { ActiveStore(otherRouteId), ActiveStore(otherRouteId) };
|
||||
var storeIds = stores.Select(s => s.Id).ToList();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
||||
|
||||
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.All(stores, s => Assert.Equal(route.Id, s.FK_RouteId));
|
||||
_repo.Verify(r => r.UpdateStore(It.IsAny<Store>()), Times.Exactly(2));
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignStoresToRouteAsync_Rollbacks_WhenUpdateThrows()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var stores = new List<Store> { ActiveStore(route.Id) };
|
||||
var storeIds = stores.Select(s => s.Id).ToList();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
||||
_repo.Setup(r => r.UpdateStore(It.IsAny<Store>())).Throws(new Exception("db error"));
|
||||
|
||||
await Assert.ThrowsAsync<Exception>(() => _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds)));
|
||||
|
||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
// AssignOwnerAsync
|
||||
|
||||
[Fact]
|
||||
public async Task AssignOwnerAsync_ReturnsNull_WhenRouteNotFound()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
||||
|
||||
var result = await _sut.AssignOwnerAsync(Guid.NewGuid(), new AssignRouteOwnerRequest(Guid.NewGuid()));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignOwnerAsync_ReturnsNull_WhenTargetUserNotActive()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetActiveUserAsync(It.IsAny<Guid>())).ReturnsAsync((User?)null);
|
||||
|
||||
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(Guid.NewGuid()));
|
||||
|
||||
Assert.Null(result);
|
||||
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignOwnerAsync_SetsOwner_AndCommits()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var salesRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetActiveUserAsync(salesRep.Id)).ReturnsAsync(salesRep);
|
||||
|
||||
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(salesRep.Id));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(salesRep.Id, result.OwnerUserId);
|
||||
Assert.Equal(salesRep.Id, route.FK_OwnerUserId);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignOwnerAsync_AllowsReassignment_ToDifferentSalesRep()
|
||||
{
|
||||
var firstOwner = Guid.NewGuid();
|
||||
var route = ActiveRoute(firstOwner);
|
||||
var newRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetActiveUserAsync(newRep.Id)).ReturnsAsync(newRep);
|
||||
|
||||
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(newRep.Id));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(newRep.Id, result.OwnerUserId);
|
||||
Assert.NotEqual(firstOwner, result.OwnerUserId);
|
||||
}
|
||||
|
||||
// RecordCheckInAsync
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerNotAuthenticated()
|
||||
{
|
||||
_currentUser.Setup(u => u.UserId).Returns((Guid?)null);
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_ReturnsNull_WhenRouteNotFoundOrInactive()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_ReturnsNull_WhenStoreNotOnRoute()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var store = ActiveStore(Guid.NewGuid()); // belongs to a different route
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 1, 2, null));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_NotFlagged_WhenGpsMatchesStoreLocation()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m);
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 12.9716m, 77.5946m, null));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsDeviationFlagged);
|
||||
_repo.Verify(r => r.AddVisitLogAsync(It.IsAny<RouteVisitLog>()), Times.Once);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_Flagged_WhenGpsFarFromStoreLocation()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m); // Bengaluru
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
|
||||
// Mumbai coordinates - hundreds of km away
|
||||
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 19.0760m, 72.8777m, null));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsDeviationFlagged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckInAsync_UsesCurrentUserId_AsVisitingRep()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var store = ActiveStore(route.Id);
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
|
||||
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, store.Latitude, store.Longitude, null));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(RepUserId, result.UserId);
|
||||
}
|
||||
|
||||
// RecordCheckOutAsync
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckOutAsync_ReturnsNull_WhenNoOpenVisit()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, It.IsAny<Guid>())).ReturnsAsync((RouteVisitLog?)null);
|
||||
|
||||
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(Guid.NewGuid(), null));
|
||||
|
||||
Assert.Null(result);
|
||||
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckOutAsync_SetsCheckOutAt_AndCommits()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var storeId = Guid.NewGuid();
|
||||
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now.AddHours(-1) };
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
||||
|
||||
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CheckOutAt);
|
||||
Assert.NotNull(log.CheckOutAt);
|
||||
_repo.Verify(r => r.UpdateVisitLog(log), Times.Once);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordCheckOutAsync_Rollbacks_WhenUpdateThrows()
|
||||
{
|
||||
var route = ActiveRoute();
|
||||
var storeId = Guid.NewGuid();
|
||||
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now };
|
||||
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
||||
_repo.Setup(r => r.UpdateVisitLog(It.IsAny<RouteVisitLog>())).Throws(new Exception("db error"));
|
||||
|
||||
await Assert.ThrowsAsync<Exception>(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null)));
|
||||
|
||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Route;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Store;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class StoreServiceTests
|
||||
{
|
||||
private readonly Mock<IStoreRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||
private readonly Mock<IRouteRepository> _routeRepo = new();
|
||||
private readonly StoreService _sut;
|
||||
|
||||
public StoreServiceTests()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(50); // global scope, skips per-store scope lookups
|
||||
var schemeService = new SchemeService(
|
||||
Mock.Of<ISchemeRepository>(),
|
||||
Mock.Of<IOrderRepository>(),
|
||||
Mock.Of<IItemRepository>(),
|
||||
_repo.Object,
|
||||
Mock.Of<IStockRepository>(),
|
||||
_uow.Object);
|
||||
_sut = new StoreService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object, schemeService, _routeRepo.Object);
|
||||
}
|
||||
|
||||
private static Store ActiveStore() =>
|
||||
new() { Id = Guid.NewGuid(), IsActive = true };
|
||||
|
||||
// GetVisitHistoryAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetVisitHistoryAsync_ReturnsNull_WhenStoreNotFound()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Store?)null);
|
||||
|
||||
var result = await _sut.GetVisitHistoryAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVisitHistoryAsync_ReturnsEmpty_WhenNoVisitsLogged()
|
||||
{
|
||||
var store = ActiveStore();
|
||||
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([]);
|
||||
|
||||
var result = await _sut.GetVisitHistoryAsync(store.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVisitHistoryAsync_MapsVisits_MostRecentFirst()
|
||||
{
|
||||
var store = ActiveStore();
|
||||
var repId = Guid.NewGuid();
|
||||
var older = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-2), CheckOutAt = DateTime.Now.AddDays(-2).AddHours(1) };
|
||||
var newer = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-1), CheckOutAt = null };
|
||||
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
|
||||
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([older, newer]);
|
||||
|
||||
var result = await _sut.GetVisitHistoryAsync(store.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(newer.Id, result[0].VisitId);
|
||||
Assert.Equal("In Progress", result[0].Outcome);
|
||||
Assert.Equal(older.Id, result[1].VisitId);
|
||||
Assert.Equal("Completed", result[1].Outcome);
|
||||
Assert.Equal(repId, result[0].RepUserId);
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<Claim> Claims { get; set; }
|
||||
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||
public DbSet<RegionalTarget> RegionalTargets { get; set; }
|
||||
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
|
||||
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -378,33 +373,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(t => t.FK_OrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<RegionalTarget>()
|
||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionalBudget>()
|
||||
.HasIndex(b => new { b.FK_RegionId, b.Period })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionAgencyMap>()
|
||||
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionAgencyMap>()
|
||||
.HasOne(m => m.AgencyUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FK_AgencyUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<RegionalTaxRule>()
|
||||
.HasIndex(r => r.FK_RegionId)
|
||||
.IsUnique();
|
||||
|
||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||
Seed.CommerceSeed.Apply(modelBuilder);
|
||||
Seed.OperationsExtrasSeed.Apply(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class CommerceSeed
|
||||
{
|
||||
private static readonly Guid PriceList1Id = new("39000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PriceListItem1Id = new("40000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PriceListItem2Id = new("40000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid Scheme1Id = new("48000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid Scheme2Id = new("48000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid SchemeBundleComponent1Id = new("49000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid CreditNote1Id = new("54000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid Claim1Id = new("53000000-0000-0000-0000-000000000001");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<PriceList>().HasData(
|
||||
new PriceList { Id = PriceList1Id, Name = "Western Province Price List", FK_RegionId = MasterDataSeed.WesternRegionId, EffectiveFrom = new DateTime(2026, 1, 1), EffectiveTo = new DateTime(2026, 12, 31), CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<PriceListItem>().HasData(
|
||||
new PriceListItem { Id = PriceListItem1Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Price = 60m, MinPrice = 55m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true },
|
||||
new PriceListItem { Id = PriceListItem2Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Price = 50m, MinPrice = 45m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Scheme>().HasData(
|
||||
new Scheme
|
||||
{
|
||||
Id = Scheme1Id,
|
||||
Name = "Buy 10 Coke Get 1 Free",
|
||||
RuleType = SchemeRuleType.BuyXGetY,
|
||||
RuleConfig = null,
|
||||
FK_TriggerItemId = MasterDataSeed.Coke500ItemId,
|
||||
TriggerQty = 10,
|
||||
FK_FreeItemId = MasterDataSeed.Coke500ItemId,
|
||||
FreeQty = 1m,
|
||||
FK_RegionId = MasterDataSeed.WesternRegionId,
|
||||
EligibleSegment = null,
|
||||
DateRangeFrom = new DateTime(2026, 8, 1),
|
||||
DateRangeTo = new DateTime(2026, 12, 31),
|
||||
AgentLimitExempt = false,
|
||||
BaselineExpectedSales = 500000m,
|
||||
CreatedAt = new DateTime(2026, 8, 1),
|
||||
IsActive = true
|
||||
},
|
||||
new Scheme
|
||||
{
|
||||
Id = Scheme2Id,
|
||||
Name = "Snack Combo Bundle",
|
||||
RuleType = SchemeRuleType.FreeBundle,
|
||||
RuleConfig = null,
|
||||
FK_TriggerItemId = null,
|
||||
TriggerQty = null,
|
||||
FK_FreeItemId = MasterDataSeed.LaysFamilyItemId,
|
||||
FreeQty = 1m,
|
||||
FK_RegionId = MasterDataSeed.CentralRegionId,
|
||||
EligibleSegment = null,
|
||||
DateRangeFrom = new DateTime(2026, 8, 1),
|
||||
DateRangeTo = new DateTime(2026, 12, 31),
|
||||
AgentLimitExempt = false,
|
||||
BaselineExpectedSales = null,
|
||||
CreatedAt = new DateTime(2026, 8, 1),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SchemeBundleComponent>().HasData(
|
||||
new SchemeBundleComponent { Id = SchemeBundleComponent1Id, FK_SchemeId = Scheme2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Qty = 5 }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SchemeRedemption>().HasData(
|
||||
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Payment>().HasData(
|
||||
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SaleReturn>().HasData(
|
||||
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>().HasData(
|
||||
new SaleReturnItem { Id = SaleReturnItem1Id, FK_SaleReturnId = SaleReturn1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 5m, ReasonCode = ReturnReasonCode.Damaged, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<CreditNote>().HasData(
|
||||
new CreditNote { Id = CreditNote1Id, FK_SaleReturnId = SaleReturn1Id, Amount = 300m, IssuedAt = new DateTime(2026, 8, 26), CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Claim>().HasData(
|
||||
new Claim
|
||||
{
|
||||
Id = Claim1Id,
|
||||
FK_AgencyUserId = OperationsExtrasSeed.RegionalAgency1UserId,
|
||||
Details = "Distributor margin claim for Q3 promotional shortfall",
|
||||
Status = ClaimStatus.Settled,
|
||||
SettlementAmount = 15000m,
|
||||
SettledByUserId = MasterDataSeed.RegionalManager1UserId,
|
||||
SettledAt = new DateTime(2026, 8, 28),
|
||||
CreatedAt = new DateTime(2026, 8, 27),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class MasterDataSeed
|
||||
{
|
||||
private static readonly DateTime SeedDate = new(2026, 8, 20);
|
||||
|
||||
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
|
||||
// Implementation Notes in the plan; not derivable from HasData at migration time.
|
||||
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
|
||||
|
||||
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
|
||||
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid RegionalManagerRoleId = new("10000000-0000-0000-0000-000000000005");
|
||||
|
||||
internal static readonly Guid BeveragesCategoryId = new("20000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid SnacksCategoryId = new("20000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid CocaColaBrandId = new("21000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid LaysBrandId = new("21000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid PieceUomId = new("22000000-0000-0000-0000-000000000001");
|
||||
|
||||
internal static readonly Guid Coke500ItemId = new("23000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid Coke1500ItemId = new("23000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid LaysClassicItemId = new("23000000-0000-0000-0000-000000000003");
|
||||
internal static readonly Guid LaysFamilyItemId = new("23000000-0000-0000-0000-000000000004");
|
||||
|
||||
internal static readonly Guid WesternRegionId = new("24000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid CentralRegionId = new("24000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid ColomboRouteId = new("25000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid NegomboRouteId = new("25000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid KandyRouteId = new("25000000-0000-0000-0000-000000000003");
|
||||
internal static readonly Guid MataleRouteId = new("25000000-0000-0000-0000-000000000004");
|
||||
|
||||
internal static readonly Guid FortStoreId = new("26000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid DehiwalaStoreId = new("26000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid WattalaStoreId = new("26000000-0000-0000-0000-000000000003");
|
||||
internal static readonly Guid NegomboStoreId = new("26000000-0000-0000-0000-000000000004");
|
||||
internal static readonly Guid KandyStoreId = new("26000000-0000-0000-0000-000000000005");
|
||||
internal static readonly Guid MataleStoreId = new("26000000-0000-0000-0000-000000000006");
|
||||
|
||||
internal static readonly Guid ColomboWarehouseId = new("27000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid KandyWarehouseId = new("27000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid VanVehicleId = new("28000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid TruckVehicleId = new("28000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid Supervisor1UserId = new("29000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid SalesRep1UserId = new("29000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid SalesRep2UserId = new("29000000-0000-0000-0000-000000000003");
|
||||
internal static readonly Guid Driver1UserId = new("29000000-0000-0000-0000-000000000004");
|
||||
internal static readonly Guid Driver2UserId = new("29000000-0000-0000-0000-000000000005");
|
||||
internal static readonly Guid RegionalManager1UserId = new("29000000-0000-0000-0000-000000000006");
|
||||
|
||||
internal static readonly Guid Driver1ProfileId = new("30000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid Driver2ProfileId = new("30000000-0000-0000-0000-000000000002");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Category>().HasData(
|
||||
new Category { Id = BeveragesCategoryId, Name = "Beverages", CreatedAt = SeedDate, IsActive = true },
|
||||
new Category { Id = SnacksCategoryId, Name = "Snacks", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Brand>().HasData(
|
||||
new Brand { Id = CocaColaBrandId, Name = "Coca-Cola", CreatedAt = SeedDate, IsActive = true },
|
||||
new Brand { Id = LaysBrandId, Name = "Lays", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Uom>().HasData(
|
||||
new Uom { Id = PieceUomId, Name = "Piece", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Item>().HasData(
|
||||
new Item { Id = Coke500ItemId, Sku = "BEV-COKE-500", Name = "Coca-Cola 500ml", Description = "Coca-Cola 500ml bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 60m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||
new Item { Id = Coke1500ItemId, Sku = "BEV-COKE-1500", Name = "Coca-Cola 1.5L", Description = "Coca-Cola 1.5L bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 120m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||
new Item { Id = LaysClassicItemId, Sku = "SNK-LAYS-52", Name = "Lays Classic 52g", Description = "Lays Classic Salted 52g pack", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 50m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||
new Item { Id = LaysFamilyItemId, Sku = "SNK-LAYS-FAM", Name = "Lays Family Pack 150g", Description = "Lays Family Pack 150g", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 150m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Region>().HasData(
|
||||
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Models.Route>().HasData(
|
||||
new Models.Route { Id = ColomboRouteId, RouteName = "Colombo City Route", StartPoint = "Colombo Fort", EndPoint = "Dehiwala", Length = 18.5m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new Models.Route { Id = NegomboRouteId, RouteName = "Negombo Coastal Route", StartPoint = "Wattala", EndPoint = "Negombo", Length = 32.0m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new Models.Route { Id = KandyRouteId, RouteName = "Kandy Town Route", StartPoint = "Kandy Lake", EndPoint = "Peradeniya", Length = 12.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new Models.Route { Id = MataleRouteId, RouteName = "Matale Route", StartPoint = "Kandy", EndPoint = "Matale", Length = 26.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Store>().HasData(
|
||||
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Warehouse>().HasData(
|
||||
new Warehouse { Id = ColomboWarehouseId, Name = "Colombo Distribution Center", Location = "Peliyagoda, Colombo", Type = WareHouseType.DistributionCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true },
|
||||
new Warehouse { Id = KandyWarehouseId, Name = "Kandy Fulfillment Center", Location = "Katugastota, Kandy", Type = WareHouseType.FulfillmentCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Vehicle>().HasData(
|
||||
new Vehicle { Id = VanVehicleId, RegNumber = "WP-CAB-1234", NumberPlate = "CAB-1234", Type = VehicleType.Van, DefaultWarehouseId = ColomboWarehouseId, CreatedAt = SeedDate, IsActive = true },
|
||||
new Vehicle { Id = TruckVehicleId, RegNumber = "CP-KY-5678", NumberPlate = "KY-5678", Type = VehicleType.Truck, DefaultWarehouseId = KandyWarehouseId, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<User>().HasData(
|
||||
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<DriverProfile>().HasData(
|
||||
new DriverProfile { Id = Driver1ProfileId, FK_UserId = Driver1UserId, LicenseNumber = "B1234567", LicenseExpiry = new DateTime(2027, 6, 30), MedicalClearanceExpiry = new DateTime(2027, 1, 31), CreatedAt = SeedDate, IsActive = true },
|
||||
new DriverProfile { Id = Driver2ProfileId, FK_UserId = Driver2UserId, LicenseNumber = "B7654321", LicenseExpiry = new DateTime(2027, 9, 30), MedicalClearanceExpiry = new DateTime(2027, 3, 31), CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class OperationsDataSeed
|
||||
{
|
||||
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
|
||||
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
|
||||
|
||||
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
|
||||
|
||||
internal static readonly Guid LoadingSheetItem1Id = new("36000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid LoadingSheetItem2Id = new("36000000-0000-0000-0000-000000000002");
|
||||
|
||||
internal static readonly Guid DeliveryTask1Id = new("37000000-0000-0000-0000-000000000001");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DeliveryRun>().HasData(
|
||||
new DeliveryRun
|
||||
{
|
||||
Id = ClosedSaleRunId,
|
||||
RideType = RideType.Sale,
|
||||
Status = RideStatus.Closed,
|
||||
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
||||
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
||||
FK_RouteId = MasterDataSeed.ColomboRouteId,
|
||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
||||
Meterstart = 10500,
|
||||
Meterend = 10550,
|
||||
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
|
||||
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
|
||||
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
|
||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||
IsActive = true
|
||||
},
|
||||
new DeliveryRun
|
||||
{
|
||||
Id = ActiveDeliveryRunId,
|
||||
RideType = RideType.Delivery,
|
||||
Status = RideStatus.Active,
|
||||
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
||||
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
||||
FK_RouteId = MasterDataSeed.KandyRouteId,
|
||||
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
|
||||
Meterstart = 20300,
|
||||
Meterend = 0,
|
||||
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
||||
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
|
||||
ClosedAt = null,
|
||||
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Sale>().HasData(
|
||||
new Sale
|
||||
{
|
||||
Id = Sale1Id,
|
||||
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
|
||||
FK_RideId = ClosedSaleRunId,
|
||||
TotalAmount = 3000m,
|
||||
Status = SaleStatus.Completed,
|
||||
PaymentStatus = PaymentStatus.Paid,
|
||||
FulfillImmediately = false,
|
||||
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
|
||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||
IsActive = true
|
||||
},
|
||||
new Sale
|
||||
{
|
||||
Id = Sale2Id,
|
||||
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
|
||||
FK_RideId = ActiveDeliveryRunId,
|
||||
TotalAmount = 1200m,
|
||||
Status = SaleStatus.Pending,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
FulfillImmediately = false,
|
||||
CompletedAt = null,
|
||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Order>().HasData(
|
||||
new Order
|
||||
{
|
||||
Id = Order1Id,
|
||||
FK_SaleId = Sale1Id,
|
||||
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||
TotalAmount = 3000m,
|
||||
Status = OrderStatus.Fulfilled,
|
||||
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||
IsActive = true
|
||||
},
|
||||
new Order
|
||||
{
|
||||
Id = Order2Id,
|
||||
FK_SaleId = Sale2Id,
|
||||
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||
TotalAmount = 1200m,
|
||||
Status = OrderStatus.Pending,
|
||||
FulfilledAt = null,
|
||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<OrderItem>().HasData(
|
||||
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<LoadingSheet>().HasData(
|
||||
new LoadingSheet
|
||||
{
|
||||
Id = LoadingSheet1Id,
|
||||
FK_RideId = ClosedSaleRunId,
|
||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
||||
Status = LoadingSheetStatus.ReceiptConfirmed,
|
||||
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
||||
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
|
||||
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
|
||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
||||
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
|
||||
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<DeliveryTask>().HasData(
|
||||
new DeliveryTask
|
||||
{
|
||||
Id = DeliveryTask1Id,
|
||||
FK_RideId = ActiveDeliveryRunId,
|
||||
FK_OrderId = Order2Id,
|
||||
Sequence = 1,
|
||||
Status = DeliveryTaskStatus.Pending,
|
||||
CustomerConfirmed = false,
|
||||
ConfirmedAt = null,
|
||||
Note = null,
|
||||
CreatedAt = new DateTime(2026, 8, 30, 10, 5, 0),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class OperationsExtrasSeed
|
||||
{
|
||||
// Role id matches RegionalAgency in Data/Seed/RoleAndPermissionSeed.cs (private there).
|
||||
private static readonly Guid RegionalAgencyRoleId = new("10000000-0000-0000-0000-000000000006");
|
||||
|
||||
internal static readonly Guid RegionalAgency1UserId = new("70000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid CaseUomId = new("67000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid UomConversion1Id = new("67000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid RegionAgencyMap1Id = new("57000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid RegionalBudget1Id = new("58000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid RegionalTarget1Id = new("59000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid RegionalTaxRule1Id = new("60000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid StockBalance1Id = new("61000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid StockBalance2Id = new("61000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid StockBalance3Id = new("61000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static readonly Guid StockMovement1Id = new("62000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid UserRegionScope1Id = new("68000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid UserRouteScope1Id = new("68000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid UserStoreScope1Id = new("68000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static readonly Guid SaleRoute1Id = new("69000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SaleRoute2Id = new("69000000-0000-0000-0000-000000000002");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>().HasData(
|
||||
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Uom>().HasData(
|
||||
new Uom { Id = CaseUomId, Name = "Case", CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<UomConversion>().HasData(
|
||||
new UomConversion { Id = UomConversion1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_FromUomId = CaseUomId, FK_ToUomId = MasterDataSeed.PieceUomId, Factor = 24m }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RegionAgencyMap>().HasData(
|
||||
new RegionAgencyMap { Id = RegionAgencyMap1Id, FK_RegionId = MasterDataSeed.WesternRegionId, FK_AgencyUserId = RegionalAgency1UserId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RegionalBudget>().HasData(
|
||||
new RegionalBudget { Id = RegionalBudget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", BudgetAmount = 200000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RegionalTarget>().HasData(
|
||||
new RegionalTarget { Id = RegionalTarget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", TargetValue = 500000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RegionalTaxRule>().HasData(
|
||||
new RegionalTaxRule { Id = RegionalTaxRule1Id, FK_RegionId = MasterDataSeed.WesternRegionId, RuleConfig = "{\"rate\": 0.15}", CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<StockBalance>().HasData(
|
||||
new StockBalance { Id = StockBalance1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 150m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
|
||||
new StockBalance { Id = StockBalance2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 90m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
|
||||
new StockBalance { Id = StockBalance3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.VanVehicleId, LocationType = LocationType.Vehicle, Quantity = 20m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<StockMovement>().HasData(
|
||||
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = null, FromLocationType = null, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<StoreKycDocument>().HasData(
|
||||
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
|
||||
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<DriverComplianceConfig>().HasData(
|
||||
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RideCancelRequest>().HasData(
|
||||
new RideCancelRequest
|
||||
{
|
||||
Id = RideCancelRequest1Id,
|
||||
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
|
||||
TypeCode = CancelRequestType.VehicleBreakdown,
|
||||
Reason = "Reported minor engine issue, resolved after inspection",
|
||||
RequestedBy = MasterDataSeed.Driver2UserId,
|
||||
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
|
||||
Status = CancelRequestStatus.Rejected,
|
||||
ApprovedBy = MasterDataSeed.Supervisor1UserId,
|
||||
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<RouteVisitLog>().HasData(
|
||||
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<UserRegionScope>().HasData(
|
||||
new UserRegionScope { Id = UserRegionScope1Id, FK_UserId = MasterDataSeed.RegionalManager1UserId, FK_RegionId = MasterDataSeed.WesternRegionId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<UserRouteScope>().HasData(
|
||||
new UserRouteScope { Id = UserRouteScope1Id, FK_UserId = MasterDataSeed.Supervisor1UserId, FK_RouteId = MasterDataSeed.ColomboRouteId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<UserStoreScope>().HasData(
|
||||
new UserStoreScope { Id = UserStoreScope1Id, FK_UserId = MasterDataSeed.SalesRep2UserId, FK_StoreId = MasterDataSeed.KandyStoreId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SaleRoute>().HasData(
|
||||
new SaleRoute { Id = SaleRoute1Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true },
|
||||
new SaleRoute { Id = SaleRoute2Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class ProcurementSeed
|
||||
{
|
||||
private static readonly Guid Supplier1Id = new("38000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid Supplier2Id = new("38000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid PurchaseOrder1Id = new("41000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PurchaseOrder2Id = new("41000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid PurchaseOrderItem1Id = new("42000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PurchaseOrderItem2Id = new("42000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid PurchaseOrderItem3Id = new("42000000-0000-0000-0000-000000000003");
|
||||
|
||||
internal static readonly Guid GoodsReceipt1Id = new("43000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid GoodsReceiptItem1Id = new("44000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid GoodsReceiptItem2Id = new("44000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid PurchaseReturn1Id = new("45000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PurchaseReturnItem1Id = new("46000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static readonly Guid SupplierClaim1Id = new("55000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SupplierClaimItem1Id = new("56000000-0000-0000-0000-000000000001");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Supplier>().HasData(
|
||||
new Supplier { Id = Supplier1Id, Name = "Ceylon Beverages Ltd", ContactInfo = "+94 11 234 5678", PerformanceScore = 4.2m, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||
new Supplier { Id = Supplier2Id, Name = "Snack Foods (Pvt) Ltd", ContactInfo = "+94 11 876 5432", PerformanceScore = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrder>().HasData(
|
||||
new PurchaseOrder { Id = PurchaseOrder1Id, FK_SupplierId = Supplier1Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||
new PurchaseOrder { Id = PurchaseOrder2Id, FK_SupplierId = Supplier2Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrderItem>().HasData(
|
||||
new PurchaseOrderItem { Id = PurchaseOrderItem1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 200, UnitCost = 40m, ReceivedQuantity = 200, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||
new PurchaseOrderItem { Id = PurchaseOrderItem2Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 100, UnitCost = 80m, ReceivedQuantity = 98, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||
new PurchaseOrderItem { Id = PurchaseOrderItem3Id, FK_PurchaseOrderId = PurchaseOrder2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 150, UnitCost = 35m, ReceivedQuantity = 150, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<GoodsReceipt>().HasData(
|
||||
new GoodsReceipt { Id = GoodsReceipt1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ReceivedByUserId = MasterDataSeed.Supervisor1UserId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, ReceivedAt = new DateTime(2026, 8, 15), CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>().HasData(
|
||||
new GoodsReceiptItem { Id = GoodsReceiptItem1Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem1Id, AcceptedQuantity = 200, RejectedQuantity = 0, CreatedAt = new DateTime(2026, 8, 15), IsActive = true },
|
||||
new GoodsReceiptItem { Id = GoodsReceiptItem2Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem2Id, AcceptedQuantity = 98, RejectedQuantity = 2, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturn>().HasData(
|
||||
new PurchaseReturn { Id = PurchaseReturn1Id, FK_PurchaseOrderId = PurchaseOrder2Id, Reason = "Damaged in transit", CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturnItem>().HasData(
|
||||
new PurchaseReturnItem { Id = PurchaseReturnItem1Id, FK_PurchaseReturnId = PurchaseReturn1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 10, CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>().HasData(
|
||||
new SupplierClaim
|
||||
{
|
||||
Id = SupplierClaim1Id,
|
||||
FK_PurchaseOrderId = PurchaseOrder1Id,
|
||||
FK_GoodsReceiptId = GoodsReceipt1Id,
|
||||
Details = "2 units of Coke 1.5L received damaged",
|
||||
EvidenceUrl = null,
|
||||
Status = SupplierClaimStatus.Approved,
|
||||
ResolutionType = SupplierClaimResolutionType.Credit,
|
||||
ResolutionAmount = 160m,
|
||||
ResolutionNotes = "Credited against next invoice",
|
||||
RaisedByUserId = MasterDataSeed.Supervisor1UserId,
|
||||
ResolvedByUserId = MasterDataSeed.RegionalManager1UserId,
|
||||
ResolvedAt = new DateTime(2026, 8, 16),
|
||||
CreatedAt = new DateTime(2026, 8, 15),
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>().HasData(
|
||||
new SupplierClaimItem { Id = SupplierClaimItem1Id, FK_SupplierClaimId = SupplierClaim1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 2m, IssueType = SupplierClaimIssueType.Damaged, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,40 +7,4 @@ public interface IRegionRepository
|
||||
Task AddAsync(Models.Region entity);
|
||||
void Update(Models.Region entity);
|
||||
void Remove(Models.Region entity);
|
||||
|
||||
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
|
||||
Task AddTargetAsync(Models.RegionalTarget target);
|
||||
void UpdateTarget(Models.RegionalTarget target);
|
||||
Task<List<Models.RegionalTarget>> GetAllTargetsAsync();
|
||||
Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id);
|
||||
void RemoveTarget(Models.RegionalTarget target);
|
||||
|
||||
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
|
||||
Task AddBudgetAsync(Models.RegionalBudget budget);
|
||||
void UpdateBudget(Models.RegionalBudget budget);
|
||||
Task<List<Models.RegionalBudget>> GetAllBudgetsAsync();
|
||||
Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id);
|
||||
void RemoveBudget(Models.RegionalBudget budget);
|
||||
|
||||
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
|
||||
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
|
||||
void UpdateAgencyMap(Models.RegionAgencyMap map);
|
||||
Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId);
|
||||
Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync();
|
||||
Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id);
|
||||
void RemoveAgencyMap(Models.RegionAgencyMap map);
|
||||
|
||||
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
|
||||
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
|
||||
void UpdateTaxRule(Models.RegionalTaxRule rule);
|
||||
Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync();
|
||||
Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id);
|
||||
void RemoveTaxRule(Models.RegionalTaxRule rule);
|
||||
|
||||
Task<int?> GetUserRoleLevelAsync(Guid userId);
|
||||
|
||||
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
||||
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
|
||||
|
||||
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
|
||||
}
|
||||
|
||||
@@ -37,38 +37,4 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
//targets/budgets/agencies/tax-rule.
|
||||
[HttpPut("{id:guid}/manage")]
|
||||
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ManageAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/hierarchy")]
|
||||
public async Task<IActionResult> GetHierarchy(Guid id)
|
||||
{
|
||||
var result = await service.GetHierarchyAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
|
||||
[HttpGet("manage-entries")]
|
||||
public async Task<IActionResult> GetManageEntries() =>
|
||||
Ok(await service.GetManageEntriesAsync());
|
||||
|
||||
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,59 +5,3 @@ public record CreateRegionRequest(string Name);
|
||||
public record UpdateRegionRequest(string Name);
|
||||
|
||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||
|
||||
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
|
||||
|
||||
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
|
||||
|
||||
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
|
||||
|
||||
// Consolidated "beyond CRUD" management endpoint — replaces the separate
|
||||
// SetTarget/SetBudget/MapAgency/SetTaxRule endpoints. Every field is optional; only the
|
||||
// sub-actions whose fields are supplied get applied. Target/Budget both need Period.
|
||||
public record ManageRegionRequest(
|
||||
string? Period,
|
||||
decimal? TargetValue,
|
||||
decimal? BudgetAmount,
|
||||
Guid? AgencyUserId,
|
||||
string? RuleConfig);
|
||||
|
||||
public record ManageRegionResponse(
|
||||
Guid RegionId,
|
||||
RegionalTargetResponse? Target,
|
||||
RegionalBudgetResponse? Budget,
|
||||
RegionAgencyMapResponse? AgencyMap,
|
||||
RegionalTaxRuleResponse? TaxRule);
|
||||
|
||||
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
|
||||
|
||||
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
|
||||
|
||||
public record RegionDashboardResponse(
|
||||
Guid RegionId,
|
||||
string Period,
|
||||
decimal? TargetValue,
|
||||
decimal? BudgetAmount,
|
||||
int OrderCount,
|
||||
decimal ActualSales,
|
||||
DateTime? LastOrderDate,
|
||||
string? TaxRuleConfig,
|
||||
List<Guid> AgencyUserIds);
|
||||
|
||||
// Flat, CRUD-list-friendly view over the four "manage" sub-resources (RegionalTarget,
|
||||
// RegionalBudget, RegionalTaxRule, RegionAgencyMap), so the frontend can render one table like
|
||||
// any other resource list (e.g. Rides) instead of one row per region with derived columns.
|
||||
public enum RegionManageEntryType { Target, Budget, TaxRule, AgencyMap }
|
||||
|
||||
public record RegionManageEntryResponse(
|
||||
Guid Id,
|
||||
Guid RegionId,
|
||||
string RegionName,
|
||||
RegionManageEntryType EntryType,
|
||||
string? Period,
|
||||
decimal? Amount,
|
||||
string? RuleConfig,
|
||||
Guid? AgencyUserId,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -22,118 +22,4 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
entity.IsActive = false;
|
||||
context.Regions.Update(entity);
|
||||
}
|
||||
|
||||
// Note: no IsActive filter on any RegionalTarget lookup below — this entity is hard-deleted
|
||||
// (see RemoveTarget), so IsActive is always true for rows that still exist. Querying without
|
||||
// the filter also means a pre-existing orphaned row (e.g. soft-deleted before this fix
|
||||
// shipped) is found and reused/cleaned up here instead of silently blocking a re-add.
|
||||
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalTargets.FirstOrDefaultAsync(t =>
|
||||
t.FK_RegionId == regionId && t.Period == period);
|
||||
|
||||
public async Task AddTargetAsync(Models.RegionalTarget target) =>
|
||||
await context.RegionalTargets.AddAsync(target);
|
||||
|
||||
public void UpdateTarget(Models.RegionalTarget target) =>
|
||||
context.RegionalTargets.Update(target);
|
||||
|
||||
public async Task<List<Models.RegionalTarget>> GetAllTargetsAsync() =>
|
||||
await context.RegionalTargets.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id) =>
|
||||
await context.RegionalTargets.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
// Hard delete, not the usual IsActive soft-delete: RegionalTarget has a unique index on
|
||||
// (FK_RegionId, Period) at the DB level, which doesn't know about IsActive — soft-deleting
|
||||
// and then re-adding a target for the same region+period would violate that constraint.
|
||||
public void RemoveTarget(Models.RegionalTarget target) =>
|
||||
context.RegionalTargets.Remove(target);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalBudget.
|
||||
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
|
||||
b.FK_RegionId == regionId && b.Period == period);
|
||||
|
||||
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
|
||||
await context.RegionalBudgets.AddAsync(budget);
|
||||
|
||||
public void UpdateBudget(Models.RegionalBudget budget) =>
|
||||
context.RegionalBudgets.Update(budget);
|
||||
|
||||
public async Task<List<Models.RegionalBudget>> GetAllBudgetsAsync() =>
|
||||
await context.RegionalBudgets.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id) =>
|
||||
await context.RegionalBudgets.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same unique-index-vs-soft-delete conflict
|
||||
// applies to RegionalBudget's (FK_RegionId, Period) unique index.
|
||||
public void RemoveBudget(Models.RegionalBudget budget) =>
|
||||
context.RegionalBudgets.Remove(budget);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionAgencyMap.
|
||||
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
|
||||
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
|
||||
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId);
|
||||
|
||||
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
|
||||
await context.RegionAgencyMaps.AddAsync(map);
|
||||
|
||||
public void UpdateAgencyMap(Models.RegionAgencyMap map) =>
|
||||
context.RegionAgencyMaps.Update(map);
|
||||
|
||||
public async Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId) =>
|
||||
await context.RegionAgencyMaps.Where(m => m.FK_RegionId == regionId).ToListAsync();
|
||||
|
||||
public async Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync() =>
|
||||
await context.RegionAgencyMaps.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id) =>
|
||||
await context.RegionAgencyMaps.FirstOrDefaultAsync(m => m.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionAgencyMap's
|
||||
// (FK_RegionId, FK_AgencyUserId) unique index.
|
||||
public void RemoveAgencyMap(Models.RegionAgencyMap map) =>
|
||||
context.RegionAgencyMaps.Remove(map);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalTaxRule.
|
||||
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId);
|
||||
|
||||
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
|
||||
await context.RegionalTaxRules.AddAsync(rule);
|
||||
|
||||
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
|
||||
context.RegionalTaxRules.Update(rule);
|
||||
|
||||
public async Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync() =>
|
||||
await context.RegionalTaxRules.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id) =>
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionalTaxRule's
|
||||
// unique index on FK_RegionId alone (one tax rule per region).
|
||||
public void RemoveTaxRule(Models.RegionalTaxRule rule) =>
|
||||
context.RegionalTaxRules.Remove(rule);
|
||||
|
||||
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
|
||||
await context.Users
|
||||
.Where(u => u.Id == userId && u.IsActive)
|
||||
.Select(u => (int?)u.Role.Level)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
|
||||
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
|
||||
|
||||
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
|
||||
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
|
||||
|
||||
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||
await (from o in context.Orders
|
||||
join s in context.Stores on o.FK_StoreId equals s.Id
|
||||
join r in context.Routes on s.FK_RouteId equals r.Id
|
||||
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
|
||||
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
|
||||
select o).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private const int RegionalAgencyRoleLevel = 60;
|
||||
|
||||
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
||||
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
||||
private Task<bool> CanManageRegionAsync(Guid regionId) => Task.FromResult(true);
|
||||
|
||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
@@ -77,221 +70,4 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consolidated replacement for the old separate SetTarget/SetBudget/MapAgency/SetTaxRule
|
||||
// endpoints. Each sub-action only runs if its fields are present on the request, so callers
|
||||
// can update just one thing (as the UI does) or several at once in a single round trip.
|
||||
public async Task<ManageRegionResponse?> ManageAsync(Guid regionId, ManageRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
if (!await CanManageRegionAsync(regionId)) return null;
|
||||
|
||||
RegionalTargetResponse? targetResponse = null;
|
||||
RegionalBudgetResponse? budgetResponse = null;
|
||||
RegionAgencyMapResponse? agencyMapResponse = null;
|
||||
RegionalTaxRuleResponse? taxRuleResponse = null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||
{
|
||||
var target = await repo.GetTargetAsync(regionId, request.Period);
|
||||
if (target is null)
|
||||
{
|
||||
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = request.Period, TargetValue = request.TargetValue.Value };
|
||||
await repo.AddTargetAsync(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
target.TargetValue = request.TargetValue.Value;
|
||||
target.IsActive = true;
|
||||
repo.UpdateTarget(target);
|
||||
}
|
||||
targetResponse = new RegionalTargetResponse(regionId, request.Period, target.TargetValue);
|
||||
}
|
||||
|
||||
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||
{
|
||||
var budget = await repo.GetBudgetAsync(regionId, request.Period);
|
||||
if (budget is null)
|
||||
{
|
||||
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = request.Period, BudgetAmount = request.BudgetAmount.Value };
|
||||
await repo.AddBudgetAsync(budget);
|
||||
}
|
||||
else
|
||||
{
|
||||
budget.BudgetAmount = request.BudgetAmount.Value;
|
||||
budget.IsActive = true;
|
||||
repo.UpdateBudget(budget);
|
||||
}
|
||||
budgetResponse = new RegionalBudgetResponse(regionId, request.Period, budget.BudgetAmount);
|
||||
}
|
||||
|
||||
if (request.AgencyUserId is not null)
|
||||
{
|
||||
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId.Value);
|
||||
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId.Value);
|
||||
if (existing is null)
|
||||
{
|
||||
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId.Value };
|
||||
await repo.AddAgencyMapAsync(map);
|
||||
}
|
||||
else if (!existing.IsActive)
|
||||
{
|
||||
existing.IsActive = true;
|
||||
repo.UpdateAgencyMap(existing);
|
||||
}
|
||||
agencyMapResponse = new RegionAgencyMapResponse(regionId, request.AgencyUserId.Value);
|
||||
}
|
||||
|
||||
if (request.RuleConfig is not null)
|
||||
{
|
||||
var rule = await repo.GetTaxRuleAsync(regionId);
|
||||
if (rule is null)
|
||||
{
|
||||
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
||||
await repo.AddTaxRuleAsync(rule);
|
||||
}
|
||||
else
|
||||
{
|
||||
rule.RuleConfig = request.RuleConfig;
|
||||
rule.IsActive = true;
|
||||
repo.UpdateTaxRule(rule);
|
||||
}
|
||||
taxRuleResponse = new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
|
||||
}
|
||||
|
||||
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
var routes = await repo.GetRoutesAsync(regionId);
|
||||
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
|
||||
var routeItems = routes
|
||||
.Select(r => new RegionHierarchyRouteItem(
|
||||
r.Id,
|
||||
r.RouteName,
|
||||
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
|
||||
.ToList();
|
||||
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
|
||||
}
|
||||
|
||||
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
|
||||
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
|
||||
var normalizedPeriod = periodStart.ToString("yyyy-MM");
|
||||
var periodEndExclusive = periodStart.AddMonths(1);
|
||||
|
||||
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
||||
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
||||
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
||||
var taxRule = await repo.GetTaxRuleAsync(regionId);
|
||||
var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId);
|
||||
|
||||
return new RegionDashboardResponse(
|
||||
regionId,
|
||||
normalizedPeriod,
|
||||
target?.TargetValue,
|
||||
budget?.BudgetAmount,
|
||||
orders.Count,
|
||||
orders.Sum(o => o.TotalAmount),
|
||||
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
|
||||
taxRule?.RuleConfig,
|
||||
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
||||
}
|
||||
|
||||
public async Task<List<RegionManageEntryResponse>> GetManageEntriesAsync()
|
||||
{
|
||||
var regionNames = (await repo.GetAllAsync()).ToDictionary(r => r.Id, r => r.Name);
|
||||
string RegionName(Guid id) => regionNames.TryGetValue(id, out var name) ? name : "Unknown";
|
||||
|
||||
var entries = new List<RegionManageEntryResponse>();
|
||||
|
||||
entries.AddRange((await repo.GetAllTargetsAsync()).Select(t =>
|
||||
new RegionManageEntryResponse(t.Id, t.FK_RegionId, RegionName(t.FK_RegionId), RegionManageEntryType.Target, t.Period, t.TargetValue, null, null, t.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllBudgetsAsync()).Select(b =>
|
||||
new RegionManageEntryResponse(b.Id, b.FK_RegionId, RegionName(b.FK_RegionId), RegionManageEntryType.Budget, b.Period, b.BudgetAmount, null, null, b.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllTaxRulesAsync()).Select(r =>
|
||||
new RegionManageEntryResponse(r.Id, r.FK_RegionId, RegionName(r.FK_RegionId), RegionManageEntryType.TaxRule, null, null, r.RuleConfig, null, r.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllAgencyMapsAsync()).Select(m =>
|
||||
new RegionManageEntryResponse(m.Id, m.FK_RegionId, RegionName(m.FK_RegionId), RegionManageEntryType.AgencyMap, null, null, null, m.FK_AgencyUserId, m.CreatedAt)));
|
||||
|
||||
return entries.OrderByDescending(e => e.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteManageEntryAsync(RegionManageEntryType entryType, Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
bool found;
|
||||
switch (entryType)
|
||||
{
|
||||
case RegionManageEntryType.Target:
|
||||
var target = await repo.GetTargetByIdAsync(id);
|
||||
found = target is not null;
|
||||
if (target is not null) repo.RemoveTarget(target);
|
||||
break;
|
||||
case RegionManageEntryType.Budget:
|
||||
var budget = await repo.GetBudgetByIdAsync(id);
|
||||
found = budget is not null;
|
||||
if (budget is not null) repo.RemoveBudget(budget);
|
||||
break;
|
||||
case RegionManageEntryType.TaxRule:
|
||||
var rule = await repo.GetTaxRuleByIdAsync(id);
|
||||
found = rule is not null;
|
||||
if (rule is not null) repo.RemoveTaxRule(rule);
|
||||
break;
|
||||
case RegionManageEntryType.AgencyMap:
|
||||
var map = await repo.GetAgencyMapByIdAsync(id);
|
||||
found = map is not null;
|
||||
if (map is not null) repo.RemoveAgencyMap(map);
|
||||
break;
|
||||
default:
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,4 @@ public interface IRouteRepository
|
||||
Task AddAsync(Models.Route entity);
|
||||
void Update(Models.Route entity);
|
||||
void Remove(Models.Route entity);
|
||||
|
||||
Task<Models.User?> GetActiveUserAsync(Guid userId);
|
||||
Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds);
|
||||
Task<Models.Store?> GetStoreByIdAsync(Guid storeId);
|
||||
void UpdateStore(Models.Store store);
|
||||
|
||||
Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId);
|
||||
Task AddVisitLogAsync(Models.RouteVisitLog log);
|
||||
void UpdateVisitLog(Models.RouteVisitLog log);
|
||||
Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId);
|
||||
}
|
||||
|
||||
@@ -31,49 +31,10 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/region")]
|
||||
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignToRegionAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/stores")]
|
||||
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignStoresToRouteAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/owner")]
|
||||
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignOwnerAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/optimize")]
|
||||
public IActionResult OptimizeSequence(Guid id) =>
|
||||
StatusCode(StatusCodes.Status501NotImplemented);
|
||||
|
||||
[HttpPost("{id:guid}/visits/check-in")]
|
||||
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.RecordCheckInAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/visits/check-out")]
|
||||
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.RecordCheckOutAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,4 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
|
||||
|
||||
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
||||
|
||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record AssignRouteToRegionRequest(Guid RegionId);
|
||||
|
||||
public record AssignStoresToRouteRequest(List<Guid> StoreIds);
|
||||
|
||||
public record AssignRouteOwnerRequest(Guid SalesRepId);
|
||||
|
||||
public record RecordCheckInRequest(Guid StoreId, decimal Latitude, decimal Longitude, DateTime? Timestamp);
|
||||
|
||||
public record RecordCheckOutRequest(Guid StoreId, DateTime? Timestamp);
|
||||
|
||||
public record RouteVisitResponse(Guid Id, Guid RouteId, Guid StoreId, Guid UserId, DateTime CheckInAt, bool IsDeviationFlagged, DateTime? CheckOutAt);
|
||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
@@ -22,31 +22,4 @@ public class RouteRepository(AppDBContext context) : IRouteRepository
|
||||
entity.IsActive = false;
|
||||
context.Routes.Update(entity);
|
||||
}
|
||||
|
||||
public async Task<Models.User?> GetActiveUserAsync(Guid userId) =>
|
||||
await context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
|
||||
|
||||
public async Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds) =>
|
||||
await context.Stores.Where(s => storeIds.Contains(s.Id)).ToListAsync();
|
||||
|
||||
public async Task<Models.Store?> GetStoreByIdAsync(Guid storeId) =>
|
||||
await context.Stores.FindAsync(storeId);
|
||||
|
||||
public void UpdateStore(Models.Store store) =>
|
||||
context.Stores.Update(store);
|
||||
|
||||
public async Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId) =>
|
||||
await context.RouteVisitLogs
|
||||
.Where(v => v.FK_RouteId == routeId && v.FK_StoreId == storeId && v.CheckOutAt == null)
|
||||
.OrderByDescending(v => v.CheckInAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task AddVisitLogAsync(Models.RouteVisitLog log) =>
|
||||
await context.RouteVisitLogs.AddAsync(log);
|
||||
|
||||
public void UpdateVisitLog(Models.RouteVisitLog log) =>
|
||||
context.RouteVisitLogs.Update(log);
|
||||
|
||||
public async Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId) =>
|
||||
await context.RouteVisitLogs.Where(v => v.FK_StoreId == storeId).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -1,50 +1,20 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private const int RouteManagerRoleLevel = 20;
|
||||
private const double DeviationThresholdMeters = 500;
|
||||
|
||||
private async Task<bool> CanManageRouteAsync(Guid routeId)
|
||||
{
|
||||
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
|
||||
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
|
||||
}
|
||||
|
||||
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
|
||||
{
|
||||
const double earthRadiusMeters = 6371000;
|
||||
var dLat = ToRadians((double)(lat2 - lat1));
|
||||
var dLon = ToRadians((double)(lon2 - lon1));
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
||||
Math.Cos(ToRadians((double)lat1)) * Math.Cos(ToRadians((double)lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
return earthRadiusMeters * c;
|
||||
}
|
||||
|
||||
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
|
||||
|
||||
private static RouteResponse ToResponse(Models.Route route) =>
|
||||
new(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
|
||||
|
||||
private static RouteVisitResponse ToVisitResponse(Models.RouteVisitLog log) =>
|
||||
new(log.Id, log.FK_RouteId, log.FK_StoreId, log.FK_UserId, log.CheckInAt, log.IsDeviationFlagged, log.CheckOutAt);
|
||||
|
||||
public async Task<List<RouteResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
|
||||
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.IsActive, r.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
public async Task<RouteResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<RouteResponse> CreateAsync(CreateRouteRequest request, CancellationToken ct = default)
|
||||
@@ -68,7 +38,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(route);
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
|
||||
@@ -91,30 +61,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(route);
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
|
||||
var region = await regionRepo.GetByIdAsync(request.RegionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
route.FK_RegionId = request.RegionId;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(route);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(route);
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
@@ -134,119 +81,4 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> AssignStoresToRouteAsync(Guid id, AssignStoresToRouteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
if (!await CanManageRouteAsync(id)) return null;
|
||||
|
||||
var stores = await repo.GetStoresByIdsAsync(request.StoreIds);
|
||||
if (stores.Count != request.StoreIds.Distinct().Count()) return null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var store in stores)
|
||||
{
|
||||
store.FK_RouteId = id;
|
||||
repo.UpdateStore(store);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(route);
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> AssignOwnerAsync(Guid id, AssignRouteOwnerRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
if (!await CanManageRouteAsync(id)) return null;
|
||||
|
||||
var salesRep = await repo.GetActiveUserAsync(request.SalesRepId);
|
||||
if (salesRep is null) return null;
|
||||
|
||||
route.FK_OwnerUserId = request.SalesRepId;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(route);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(route);
|
||||
}
|
||||
|
||||
public async Task<RouteVisitResponse?> RecordCheckInAsync(Guid id, RecordCheckInRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (currentUser.UserId is null) return null;
|
||||
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
|
||||
|
||||
var store = await repo.GetStoreByIdAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive || store.FK_RouteId != id) return null;
|
||||
|
||||
var distance = DistanceMeters(request.Latitude, request.Longitude, store.Latitude, store.Longitude);
|
||||
|
||||
var log = new Models.RouteVisitLog
|
||||
{
|
||||
FK_RouteId = id,
|
||||
FK_StoreId = request.StoreId,
|
||||
FK_UserId = currentUser.UserId.Value,
|
||||
CheckInAt = request.Timestamp ?? DateTime.Now,
|
||||
CheckInLatitude = request.Latitude,
|
||||
CheckInLongitude = request.Longitude,
|
||||
IsDeviationFlagged = distance > DeviationThresholdMeters
|
||||
};
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddVisitLogAsync(log);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToVisitResponse(log);
|
||||
}
|
||||
|
||||
public async Task<RouteVisitResponse?> RecordCheckOutAsync(Guid id, RecordCheckOutRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (currentUser.UserId is null) return null;
|
||||
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
|
||||
|
||||
var log = await repo.GetOpenVisitAsync(id, request.StoreId);
|
||||
if (log is null) return null;
|
||||
|
||||
log.CheckOutAt = request.Timestamp ?? DateTime.Now;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.UpdateVisitLog(log);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToVisitResponse(log);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -14,7 +14,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
|
||||
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
||||
{
|
||||
//if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
||||
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,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;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
return ToResponse(store);
|
||||
}
|
||||
|
||||
@@ -209,12 +209,8 @@ 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 visits = await routeRepo.GetVisitsByStoreIdAsync(id);
|
||||
return visits
|
||||
.OrderByDescending(v => v.CheckInAt)
|
||||
.Select(v => new StoreVisitHistoryItem(v.Id, v.CheckInAt, v.FK_UserId, v.CheckOutAt is null ? "In Progress" : "Completed"))
|
||||
.ToList();
|
||||
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
|
||||
return [];
|
||||
}
|
||||
|
||||
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRegionManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegionAgencyMaps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_AgencyUserId = 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_RegionAgencyMaps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
|
||||
column: x => x.FK_RegionId,
|
||||
principalTable: "Regions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
|
||||
column: x => x.FK_AgencyUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegionalBudgets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Period = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
BudgetAmount = 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_RegionalBudgets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegionalBudgets_Regions_FK_RegionId",
|
||||
column: x => x.FK_RegionId,
|
||||
principalTable: "Regions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegionalTargets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Period = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
TargetValue = 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_RegionalTargets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegionalTargets_Regions_FK_RegionId",
|
||||
column: x => x.FK_RegionId,
|
||||
principalTable: "Regions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegionalTaxRules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
|
||||
column: x => x.FK_RegionId,
|
||||
principalTable: "Regions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
|
||||
table: "RegionAgencyMaps",
|
||||
column: "FK_AgencyUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
|
||||
table: "RegionAgencyMaps",
|
||||
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegionalBudgets_FK_RegionId_Period",
|
||||
table: "RegionalBudgets",
|
||||
columns: new[] { "FK_RegionId", "Period" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegionalTargets_FK_RegionId_Period",
|
||||
table: "RegionalTargets",
|
||||
columns: new[] { "FK_RegionId", "Period" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegionalTaxRules_FK_RegionId",
|
||||
table: "RegionalTaxRules",
|
||||
column: "FK_RegionId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegionAgencyMaps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegionalBudgets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegionalTargets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegionalTaxRules");
|
||||
}
|
||||
}
|
||||
}
|
||||
-2895
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRouteVisitManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_OwnerUserId",
|
||||
table: "Routes",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RouteVisitLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_RouteId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_StoreId = 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"),
|
||||
CheckInAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
CheckInLatitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
CheckInLongitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
IsDeviationFlagged = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
CheckOutAt = 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_RouteVisitLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RouteVisitLogs_Routes_FK_RouteId",
|
||||
column: x => x.FK_RouteId,
|
||||
principalTable: "Routes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_RouteVisitLogs_Stores_FK_StoreId",
|
||||
column: x => x.FK_StoreId,
|
||||
principalTable: "Stores",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_RouteVisitLogs_Users_FK_UserId",
|
||||
column: x => x.FK_UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Routes_FK_OwnerUserId",
|
||||
table: "Routes",
|
||||
column: "FK_OwnerUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RouteVisitLogs_FK_RouteId",
|
||||
table: "RouteVisitLogs",
|
||||
column: "FK_RouteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RouteVisitLogs_FK_StoreId",
|
||||
table: "RouteVisitLogs",
|
||||
column: "FK_StoreId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RouteVisitLogs_FK_UserId",
|
||||
table: "RouteVisitLogs",
|
||||
column: "FK_UserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Routes_Users_FK_OwnerUserId",
|
||||
table: "Routes",
|
||||
column: "FK_OwnerUserId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Routes_Users_FK_OwnerUserId",
|
||||
table: "Routes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RouteVisitLogs");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Routes_FK_OwnerUserId",
|
||||
table: "Routes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_OwnerUserId",
|
||||
table: "Routes");
|
||||
}
|
||||
}
|
||||
}
|
||||
-3472
File diff suppressed because it is too large
Load Diff
@@ -1,421 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedMasterAndOperationsData : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "Brands",
|
||||
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("21000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Coca-Cola" },
|
||||
{ new Guid("21000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Lays" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Categories",
|
||||
columns: new[] { "Id", "AllowCustomerReturn", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0000-0000-000000000001"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Beverages" },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000002"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Snacks" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Regions",
|
||||
columns: new[] { "Id", "CreatedAt", "IsActive", "Name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("24000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Western Province" },
|
||||
{ new Guid("24000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Central Province" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Uoms",
|
||||
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
|
||||
values: new object[] { new Guid("22000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Piece" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Users",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000002"), "Sunil Fernando", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "sunil.fernando" },
|
||||
{ new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000005"), "Priya Rajapaksa", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priya.rajapaksa" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Warehouses",
|
||||
columns: new[] { "Id", "CreatedAt", "IsActive", "Location", "Name", "Type", "VehicleId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("27000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Peliyagoda, Colombo", "Colombo Distribution Center", 0, null },
|
||||
{ new Guid("27000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Katugastota, Kandy", "Kandy Fulfillment Center", 1, null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Items",
|
||||
columns: new[] { "Id", "CostPrice", "CreatedAt", "Description", "ExternalErpItemId", "FK_BaseUomId", "FK_BrandId", "FK_CategoryId", "FK_SubCategoryId", "IsActive", "LastSyncedAt", "Name", "SalePrice", "Sku", "SyncSource" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("23000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 500ml bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 500ml", 60m, "BEV-COKE-500", 0 },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 1.5L bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 1.5L", 120m, "BEV-COKE-1500", 0 },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000003"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Classic Salted 52g pack", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Classic 52g", 50m, "SNK-LAYS-52", 0 },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000004"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Family Pack 150g", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Family Pack 150g", 150m, "SNK-LAYS-FAM", 0 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Users",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Nimal Perera", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "nimal.perera" },
|
||||
{ new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Kamal Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "kamal.silva" },
|
||||
{ new Guid("29000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Ruwan Jayasuriya", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "ruwan.jayasuriya" },
|
||||
{ new Guid("29000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Priyantha Kumara", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priyantha.kumara" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Vehicles",
|
||||
columns: new[] { "Id", "CreatedAt", "DefaultWarehouseId", "IsActive", "NumberPlate", "RegNumber", "Type" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("28000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000001"), true, "CAB-1234", "WP-CAB-1234", 2 },
|
||||
{ new Guid("28000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000002"), true, "KY-5678", "CP-KY-5678", 1 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "DriverProfiles",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_UserId", "IsActive", "LicenseExpiry", "LicenseNumber", "MedicalClearanceExpiry" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("30000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), true, new DateTime(2027, 6, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B1234567", new DateTime(2027, 1, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) },
|
||||
{ new Guid("30000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), true, new DateTime(2027, 9, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B7654321", new DateTime(2027, 3, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Routes",
|
||||
columns: new[] { "Id", "CreatedAt", "EndPoint", "FK_OwnerUserId", "FK_RegionId", "IsActive", "Length", "RouteName", "StartPoint" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("25000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Dehiwala", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 18.5m, "Colombo City Route", "Colombo Fort" },
|
||||
{ new Guid("25000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Negombo", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 32.0m, "Negombo Coastal Route", "Wattala" },
|
||||
{ new Guid("25000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Peradeniya", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 12.0m, "Kandy Town Route", "Kandy Lake" },
|
||||
{ new Guid("25000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Matale", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 26.0m, "Matale Route", "Kandy" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "DeliveryRuns",
|
||||
columns: new[] { "Id", "ActivatedAt", "ClosedAt", "CreatedAt", "FK_DriverProfileId", "FK_RouteId", "FK_SourceWarehouseId", "FK_VehicleId", "IsActive", "Meterend", "Meterstart", "RideType", "StartedAt", "Status" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("31000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 15, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), true, 10550, 10500, 1, new DateTime(2026, 8, 25, 8, 0, 0, 0, DateTimeKind.Unspecified), 5 },
|
||||
{ new Guid("31000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 9, 20, 0, 0, DateTimeKind.Unspecified), null, new DateTime(2026, 8, 30, 8, 45, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), new Guid("25000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000002"), new Guid("28000000-0000-0000-0000-000000000002"), true, 0, 20300, 0, new DateTime(2026, 8, 30, 9, 0, 0, 0, DateTimeKind.Unspecified), 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Stores",
|
||||
columns: new[] { "Id", "CreatedAt", "CreditAmount", "CreditLimit", "FK_RouteId", "IsActive", "KycStatus", "Latitude", "Longitude", "OneTimeAmount", "OutstandingBalance", "OwnerName", "Segment", "StoreAddress", "StoreName" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 50000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.9344m, 79.8428m, 0m, 0m, "Saman Kumara", "Gold", "12 York Street, Colombo Fort", "Fort Mini Mart" },
|
||||
{ new Guid("26000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 30000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.8508m, 79.8654m, 0m, 5000m, "Nadeesha Silva", "Silver", "45 Galle Road, Dehiwala", "Dehiwala Grocery" },
|
||||
{ new Guid("26000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 40000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 6.9890m, 79.8912m, 0m, 1200m, "Ruwan Gunasekara", "Gold", "88 Negombo Road, Wattala", "Wattala Supermarket" },
|
||||
{ new Guid("26000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 25000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 7.2094m, 79.8380m, 0m, 0m, "Chamari Peiris", null, "5 Beach Road, Negombo", "Negombo Beach Store" },
|
||||
{ new Guid("26000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 35000m, new Guid("25000000-0000-0000-0000-000000000003"), true, 1, 7.2906m, 80.6337m, 0m, 0m, "Dinesh Wickramasinghe", "Key Account", "3 Lake Road, Kandy", "Kandy Lake View Store" },
|
||||
{ new Guid("26000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 20000m, new Guid("25000000-0000-0000-0000-000000000004"), true, 0, 7.4675m, 80.6234m, 0m, 0m, "Ishara Bandara", null, "20 Main Street, Matale", "Matale Central Store" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "LoadingSheets",
|
||||
columns: new[] { "Id", "CreatedAt", "DispatchConfirmedAt", "DispatchConfirmedBy", "FK_RideId", "FK_SourceWarehouseId", "IsActive", "ReceiptConfirmedAt", "ReceiptConfirmedBy", "Status" },
|
||||
values: new object[] { new Guid("35000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 25, 14, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), 2 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Sales",
|
||||
columns: new[] { "Id", "CompletedAt", "CreatedAt", "CreatedByUserId", "FK_RideId", "FK_StoreId", "FulfillImmediately", "IsActive", "PaymentStatus", "SaleDate", "Status", "TotalAmount" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("32000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), false, true, 2, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 1, 3000m },
|
||||
{ new Guid("32000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("31000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), false, true, 0, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "LoadingSheetItems",
|
||||
columns: new[] { "Id", "FK_ItemId", "FK_LoadingSheetId", "QuantityDispatched", "QuantityReceived", "QuantityResolved" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("36000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("35000000-0000-0000-0000-000000000001"), 50m, 50m, null },
|
||||
{ new Guid("36000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("35000000-0000-0000-0000-000000000001"), 30m, 30m, null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Orders",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_SaleId", "FK_StoreId", "FulfilledAt", "IsActive", "OrderDate", "Status", "TotalAmount" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), true, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, 3000m },
|
||||
{ new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), null, true, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "DeliveryTasks",
|
||||
columns: new[] { "Id", "ConfirmedAt", "CreatedAt", "CustomerConfirmed", "FK_OrderId", "FK_RideId", "IsActive", "Note", "Sequence", "Status" },
|
||||
values: new object[] { new Guid("37000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 30, 10, 5, 0, 0, DateTimeKind.Unspecified), false, new Guid("33000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000002"), true, null, 1, 0 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrderItems",
|
||||
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DeliveryTasks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("37000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000004"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "LoadingSheetItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("36000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "LoadingSheetItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("36000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000004"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000006"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000006"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "LoadingSheets",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("35000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Orders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Orders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Routes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("25000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Routes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("25000000-0000-0000-0000-000000000004"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Brands",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("21000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Brands",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("21000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Sales",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("32000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Sales",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("32000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Uoms",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("22000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DeliveryRuns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("31000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DeliveryRuns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("31000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000005"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DriverProfiles",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("30000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DriverProfiles",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("30000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Routes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("25000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Routes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("25000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Vehicles",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("28000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Vehicles",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("28000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Regions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("24000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Regions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("24000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000004"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000005"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Warehouses",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("27000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Warehouses",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("27000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("29000000-0000-0000-0000-000000000001"));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,498 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedRemainingTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "DriverComplianceConfigs",
|
||||
columns: new[] { "Id", "EnforceLicenseExpiry", "EnforceMedicalClearance", "UpdatedAt", "UpdatedBy" },
|
||||
values: new object[] { new Guid("64000000-0000-0000-0000-000000000001"), true, true, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Payments",
|
||||
columns: new[] { "Id", "Amount", "CollectedAt", "CollectedBy", "CreatedAt", "FK_SaleId", "FK_StoreId", "IsActive", "Method" },
|
||||
values: new object[] { new Guid("47000000-0000-0000-0000-000000000001"), 3000m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), true, 0 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PriceLists",
|
||||
columns: new[] { "Id", "CreatedAt", "EffectiveFrom", "EffectiveTo", "FK_RegionId", "IsActive", "Name" },
|
||||
values: new object[] { new Guid("39000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "Western Province Price List" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RegionalBudgets",
|
||||
columns: new[] { "Id", "BudgetAmount", "CreatedAt", "FK_RegionId", "IsActive", "Period" },
|
||||
values: new object[] { new Guid("58000000-0000-0000-0000-000000000001"), 200000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RegionalTargets",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "Period", "TargetValue" },
|
||||
values: new object[] { new Guid("59000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08", 500000m });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RegionalTaxRules",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "RuleConfig" },
|
||||
values: new object[] { new Guid("60000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "{\"rate\": 0.15}" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RideCancelRequests",
|
||||
columns: new[] { "Id", "ApprovedAt", "ApprovedBy", "FK_RideId", "Reason", "RequestedAt", "RequestedBy", "Status", "TypeCode" },
|
||||
values: new object[] { new Guid("65000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000002"), "Reported minor engine issue, resolved after inspection", new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), 2, 0 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RouteVisitLogs",
|
||||
columns: new[] { "Id", "CheckInAt", "CheckInLatitude", "CheckInLongitude", "CheckOutAt", "CreatedAt", "FK_RouteId", "FK_StoreId", "FK_UserId", "IsActive", "IsDeviationFlagged" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("66000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 6.9344m, 79.8428m, new DateTime(2026, 8, 25, 9, 25, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000002"), true, false },
|
||||
{ new Guid("66000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), 6.9000m, 79.8654m, null, new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000002"), new Guid("29000000-0000-0000-0000-000000000002"), true, true }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SaleReturns",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedByUserId", "FK_OrderId", "IsActive", "Mode", "TotalCreditAmount" },
|
||||
values: new object[] { new Guid("51000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000001"), true, 0, 300m });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SaleRoutes",
|
||||
columns: new[] { "Id", "CreatedAt", "IsActive" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("69000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true },
|
||||
{ new Guid("69000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Schemes",
|
||||
columns: new[] { "Id", "AgentLimitExempt", "BaselineExpectedSales", "CreatedAt", "DateRangeFrom", "DateRangeTo", "EligibleSegment", "FK_FreeItemId", "FK_RegionId", "FK_TriggerItemId", "FreeQty", "IsActive", "Name", "RegionId", "RuleConfig", "RuleType", "TriggerQty" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("48000000-0000-0000-0000-000000000001"), false, 500000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), 1m, true, "Buy 10 Coke Get 1 Free", null, null, 0, 10 },
|
||||
{ new Guid("48000000-0000-0000-0000-000000000002"), false, null, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000004"), new Guid("24000000-0000-0000-0000-000000000002"), null, 1m, true, "Snack Combo Bundle", null, null, 3, null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "StockBalances",
|
||||
columns: new[] { "Id", "FK_ItemId", "LocationId", "LocationType", "Quantity", "UpdatedAt" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("61000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 150m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
|
||||
{ new Guid("61000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 90m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
|
||||
{ new Guid("61000000-0000-0000-0000-000000000003"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 20m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "StockMovements",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ItemId", "FromLocationId", "FromLocationType", "Quantity", "ReasonCode", "ReferenceId", "ToLocationId", "ToLocationType", "Type" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("62000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), null, null, 200m, null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 6 },
|
||||
{ new Guid("62000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 50m, null, new Guid("35000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 5 },
|
||||
{ new Guid("62000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 30m, null, new Guid("32000000-0000-0000-0000-000000000001"), null, null, 0 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "StoreKycDocuments",
|
||||
columns: new[] { "Id", "CreatedAt", "DocumentType", "FK_StoreId", "FileReference", "IsActive", "UploadedAt" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("63000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), "Business Registration", new Guid("26000000-0000-0000-0000-000000000001"), "seed/kyc/fort-mini-mart-brc.pdf", true, new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified) },
|
||||
{ new Guid("63000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), "NIC", new Guid("26000000-0000-0000-0000-000000000003"), "seed/kyc/wattala-owner-nic.pdf", true, new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified) }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Suppliers",
|
||||
columns: new[] { "Id", "ContactInfo", "CreatedAt", "IsActive", "Name", "PerformanceScore" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("38000000-0000-0000-0000-000000000001"), "+94 11 234 5678", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Ceylon Beverages Ltd", 4.2m },
|
||||
{ new Guid("38000000-0000-0000-0000-000000000002"), "+94 11 876 5432", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Snack Foods (Pvt) Ltd", null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Uoms",
|
||||
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
|
||||
values: new object[] { new Guid("67000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Case" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "UserRegionScopes",
|
||||
columns: new[] { "FK_RegionId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
|
||||
values: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000001"), true });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "UserRouteScopes",
|
||||
columns: new[] { "FK_RouteId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
|
||||
values: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000002"), true });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "UserStoreScopes",
|
||||
columns: new[] { "FK_StoreId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
|
||||
values: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000003"), true });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Users",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
|
||||
values: new object[] { new Guid("70000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000006"), "Channa De Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "channa.desilva" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Claims",
|
||||
columns: new[] { "Id", "CreatedAt", "Details", "FK_AgencyUserId", "IsActive", "SettledAt", "SettledByUserId", "SettlementAmount", "Status" },
|
||||
values: new object[] { new Guid("53000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 27, 0, 0, 0, 0, DateTimeKind.Unspecified), "Distributor margin claim for Q3 promotional shortfall", new Guid("70000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 28, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 15000m, 1 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "CreditNotes",
|
||||
columns: new[] { "Id", "Amount", "CreatedAt", "FK_SaleReturnId", "IsActive", "IssuedAt" },
|
||||
values: new object[] { new Guid("54000000-0000-0000-0000-000000000001"), 300m, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("51000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified) });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PriceListItems",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PriceListId", "IsActive", "MinPrice", "Price" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("40000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("39000000-0000-0000-0000-000000000001"), true, 55m, 60m },
|
||||
{ new Guid("40000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("39000000-0000-0000-0000-000000000001"), true, 45m, 50m }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PurchaseOrders",
|
||||
columns: new[] { "Id", "CancelReason", "CreatedAt", "FK_CreatedByUserId", "FK_SupplierId", "IsActive", "Status" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("41000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000001"), true, 2 },
|
||||
{ new Guid("41000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000002"), true, 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RegionAgencyMaps",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_AgencyUserId", "FK_RegionId", "IsActive" },
|
||||
values: new object[] { new Guid("57000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("70000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), true });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SaleReturnItems",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SaleReturnId", "IsActive", "Quantity", "ReasonCode", "UnitPrice" },
|
||||
values: new object[] { new Guid("52000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("51000000-0000-0000-0000-000000000001"), true, 5m, 1, 60m });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SchemeBundleComponents",
|
||||
columns: new[] { "Id", "FK_ItemId", "FK_SchemeId", "Qty" },
|
||||
values: new object[] { new Guid("49000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("48000000-0000-0000-0000-000000000002"), 5 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SchemeRedemptions",
|
||||
columns: new[] { "Id", "DiscountAmount", "FK_FreeItemId", "FK_OrderId", "FK_SchemeId", "FieldInvoiceId", "FreeItemCostValue", "FreeQty", "RedeemedAt" },
|
||||
values: new object[] { new Guid("50000000-0000-0000-0000-000000000001"), 0m, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new Guid("48000000-0000-0000-0000-000000000001"), null, 40m, 1m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "UomConversions",
|
||||
columns: new[] { "Id", "FK_FromUomId", "FK_ItemId", "FK_ToUomId", "Factor" },
|
||||
values: new object[] { new Guid("67000000-0000-0000-0000-000000000002"), new Guid("67000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("22000000-0000-0000-0000-000000000001"), 24m });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "GoodsReceipts",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "FK_ReceivedByUserId", "FK_WarehouseId", "IsActive", "ReceivedAt" },
|
||||
values: new object[] { new Guid("43000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified) });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PurchaseOrderItems",
|
||||
columns: new[] { "Id", "CancelledQuantity", "CreatedAt", "FK_ItemId", "FK_PurchaseOrderId", "IsActive", "IsCancelled", "Quantity", "ReceivedQuantity", "UnitCost" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("42000000-0000-0000-0000-000000000001"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 200, 200, 40m },
|
||||
{ new Guid("42000000-0000-0000-0000-000000000002"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 100, 98, 80m },
|
||||
{ new Guid("42000000-0000-0000-0000-000000000003"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("41000000-0000-0000-0000-000000000002"), true, false, 150, 150, 35m }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PurchaseReturns",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "IsActive", "Reason" },
|
||||
values: new object[] { new Guid("45000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000002"), true, "Damaged in transit" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "GoodsReceiptItems",
|
||||
columns: new[] { "Id", "AcceptedQuantity", "CreatedAt", "FK_GoodsReceiptId", "FK_PurchaseOrderItemId", "IsActive", "RejectedQuantity" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("44000000-0000-0000-0000-000000000001"), 200, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000001"), true, 0 },
|
||||
{ new Guid("44000000-0000-0000-0000-000000000002"), 98, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000002"), true, 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PurchaseReturnItems",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PurchaseReturnId", "IsActive", "Quantity" },
|
||||
values: new object[] { new Guid("46000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("45000000-0000-0000-0000-000000000001"), true, 10 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SupplierClaims",
|
||||
columns: new[] { "Id", "CreatedAt", "Details", "EvidenceUrl", "FK_GoodsReceiptId", "FK_PurchaseOrderId", "IsActive", "RaisedByUserId", "ResolutionAmount", "ResolutionNotes", "ResolutionType", "ResolvedAt", "ResolvedByUserId", "Status" },
|
||||
values: new object[] { new Guid("55000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), "2 units of Coke 1.5L received damaged", null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, new Guid("29000000-0000-0000-0000-000000000001"), 160m, "Credited against next invoice", 1, new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 1 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SupplierClaimItems",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SupplierClaimId", "IsActive", "IssueType", "Quantity" },
|
||||
values: new object[] { new Guid("56000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("55000000-0000-0000-0000-000000000001"), true, 0, 2m });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Claims",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("53000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "CreditNotes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("54000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "DriverComplianceConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("64000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Payments",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("47000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PriceListItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("40000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PriceListItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("40000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseOrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("42000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseReturnItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("46000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RegionAgencyMaps",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("57000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RegionalBudgets",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("58000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RegionalTargets",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("59000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RegionalTaxRules",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("60000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RideCancelRequests",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("65000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RouteVisitLogs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("66000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RouteVisitLogs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("66000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SaleReturnItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("52000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SaleRoutes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("69000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SaleRoutes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("69000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SchemeBundleComponents",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("49000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SchemeRedemptions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("50000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StoreKycDocuments",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("63000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "StoreKycDocuments",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("63000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SupplierClaimItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("56000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "UomConversions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("67000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "UserRegionScopes",
|
||||
keyColumns: new[] { "FK_RegionId", "FK_UserId" },
|
||||
keyValues: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "UserRouteScopes",
|
||||
keyColumns: new[] { "FK_RouteId", "FK_UserId" },
|
||||
keyValues: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "UserStoreScopes",
|
||||
keyColumns: new[] { "FK_StoreId", "FK_UserId" },
|
||||
keyValues: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PriceLists",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("39000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseOrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("42000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseOrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("42000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseReturns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("45000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SaleReturns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Schemes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("48000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Schemes",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("48000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SupplierClaims",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("55000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Uoms",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("67000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("70000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "GoodsReceipts",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("43000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseOrders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("41000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "PurchaseOrders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("41000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Suppliers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("38000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Suppliers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("38000000-0000-0000-0000-000000000001"));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class RegionAgencyMap : Template
|
||||
{
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
public Region Region { get; set; }
|
||||
|
||||
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
|
||||
public Guid FK_AgencyUserId { get; set; }
|
||||
[ForeignKey("FK_AgencyUserId")]
|
||||
public User AgencyUser { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class RegionalBudget : Template
|
||||
{
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
public Region Region { get; set; }
|
||||
|
||||
public string Period { get; set; }
|
||||
public decimal BudgetAmount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class RegionalTarget : Template
|
||||
{
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
public Region Region { get; set; }
|
||||
|
||||
public string Period { get; set; }
|
||||
public decimal TargetValue { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class RegionalTaxRule : Template
|
||||
{
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
public Region Region { get; set; }
|
||||
|
||||
public string RuleConfig { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,5 @@ namespace Distribution.Models
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
public Region Region { get; set; }
|
||||
|
||||
public Guid? FK_OwnerUserId { get; set; }
|
||||
[ForeignKey("FK_OwnerUserId")]
|
||||
public User? OwnerUser { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class RouteVisitLog : Template
|
||||
{
|
||||
public Guid FK_RouteId { get; set; }
|
||||
[ForeignKey("FK_RouteId")]
|
||||
public Route Route { get; set; }
|
||||
|
||||
public Guid FK_StoreId { get; set; }
|
||||
[ForeignKey("FK_StoreId")]
|
||||
public Store Store { get; set; }
|
||||
|
||||
public Guid FK_UserId { get; set; }
|
||||
[ForeignKey("FK_UserId")]
|
||||
public User User { get; set; }
|
||||
|
||||
public DateTime CheckInAt { get; set; }
|
||||
public decimal CheckInLatitude { get; set; }
|
||||
public decimal CheckInLongitude { get; set; }
|
||||
public bool IsDeviationFlagged { get; set; } = false;
|
||||
|
||||
public DateTime? CheckOutAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.Auth;
|
||||
using Distribution.Middleware;
|
||||
@@ -145,8 +144,7 @@ builder.Services.AddCors(options =>
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
@@ -29,10 +29,6 @@ function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-s
|
||||
via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3)
|
||||
and route/region-scoped authorization for Stores and Orders.
|
||||
|
||||
> **Resolved** — `StoreDTOs.cs`'s `CreateStoreRequest`/`UpdateStoreRequest` now carry `RouteId`,
|
||||
> and a dedicated bulk-assign endpoint (`POST /api/routes/{id}/stores`) was added in
|
||||
> [11_route_sales_management.md](./11_route_sales_management.md).
|
||||
|
||||
### 3. Role Controller — Missing Authorization
|
||||
|
||||
`RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]`
|
||||
|
||||
@@ -48,7 +48,7 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr
|
||||
| `logAuditEvent(userId, action, entityType, entityId)` (§18) | Internal — called by every mutating service, not directly exposed |
|
||||
| `checkLicenseCompliance(entityId, licenseType)` (§18) | `GET /api/compliance/{entityType}/{entityId}/license-check` |
|
||||
| `generateComplianceReport(period)` (§18) | `GET /api/compliance/report?period=...` |
|
||||
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Not implemented. The underlying data now exists — `POST /api/routes/{id}/visits/check-in` ([11_route_sales_management.md](./11_route_sales_management.md)) computes and stores `IsDeviationFlagged` on each `RouteVisitLog` row (Haversine distance vs. the store's registered lat/long) — but nothing reads that flag to raise an alert/anomaly flag yet. |
|
||||
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Triggered from route check-in/out data (§3 of the function reference; not yet in any numbered step — see [11_route_sales_management.md](./11_route_sales_management.md)) |
|
||||
| — | `GET /api/audit-log?entityType=...&entityId=...` |
|
||||
| — | `GET /api/anomaly-flags?status=Open` |
|
||||
|
||||
@@ -57,9 +57,8 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr
|
||||
## Automated Checks & Flags
|
||||
|
||||
- Returns without a matching prior sale (`ReturnWithoutSale`).
|
||||
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — the GPS check-in/out data this
|
||||
depends on now exists ([11_route_sales_management.md](./11_route_sales_management.md)'s
|
||||
`RouteVisitLog.IsDeviationFlagged`), but no anomaly flag or alert reads it yet.
|
||||
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — depends on GPS check-in/out
|
||||
data from [11_route_sales_management.md](./11_route_sales_management.md).
|
||||
- Agents with consistently high variance rates across closed rides (`HighVarianceRate`) — reads
|
||||
`Variance Record` history from [07_ride_reconciliation.md](./07_ride_reconciliation.md).
|
||||
- Cash collected below invoiced amount (`CashShortfall`) — reads `Payment Record` from
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 10 — Region Management (Beyond CRUD)
|
||||
|
||||
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
|
||||
**Status**: Not Started
|
||||
**Depends On**: 00 — Existing Gap Fixes
|
||||
|
||||
---
|
||||
@@ -24,87 +24,48 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
|
||||
|
||||
### Regional Target / Budget
|
||||
|
||||
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
|
||||
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
|
||||
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
|
||||
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
|
||||
setting the same period again updates it in place rather than inserting a duplicate.
|
||||
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
|
||||
|
||||
### Agency-Region Map
|
||||
|
||||
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
|
||||
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
|
||||
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
|
||||
the same convention already used by `Claim.FK_AgencyUserId` (§18).
|
||||
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
|
||||
`Role` (see Open Questions).
|
||||
|
||||
### Regional Tax Rule
|
||||
|
||||
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
|
||||
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
|
||||
it's stored for a future pricing-rule integration.
|
||||
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Function reference | Endpoint | Status |
|
||||
|---|---|---|
|
||||
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
|
||||
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` | Implemented (real-time aggregation; see Open Questions, resolved) |
|
||||
|
||||
All request/response DTOs live in `Features/Region/RegionDTOs.cs`; routing and access checks are
|
||||
in `RegionController.cs` and `RegionService.cs`.
|
||||
|
||||
The four `set*`/`mapAgencyToRegion` actions above were later consolidated into a single
|
||||
`PUT /api/regions/{id}/manage` endpoint (`RegionService.ManageAsync`) — every field on the request
|
||||
is optional, and only the sub-action(s) whose fields are present get applied, so one call can
|
||||
update just one thing (as the frontend's `region-manage-sheet.tsx` does, one button per field) or
|
||||
several at once. See [BACKEND_API_REFERENCE.md §6.5](../../sharedDocs/BACKEND_API_REFERENCE.md#65-regions)
|
||||
for the exact request/response shape.
|
||||
| Function reference | Endpoint intent |
|
||||
|---|---|
|
||||
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
|
||||
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
|
||||
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
- ⚠️ **Temporarily disabled**: `RegionService.CanManageRegionAsync` (the gate on `ManageAsync`) was
|
||||
stubbed to always return `true` — role-based restriction is deliberately deferred pending a
|
||||
decision on the role model for this endpoint (tracked as a `TODO` in `RegionService.cs`). The
|
||||
design below is what it enforced before being stubbed out, and what it should return to:
|
||||
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
||||
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
|
||||
regions. Role level ≥ 60 (`RegionalAgency`) allowed unconditionally, role level ≥ 50
|
||||
(`RegionalManager`) requires a matching `UserRegionScope` row for that region, anything lower
|
||||
rejected.
|
||||
- `agencyUserId` (when supplied to `manage`) still rejects the target user if their role level is
|
||||
below the `RegionalAgency` threshold (60) — you can only map a user who actually holds (or
|
||||
outranks) that role. This check was not part of the disabled gate and remains active.
|
||||
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
||||
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
|
||||
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
|
||||
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
|
||||
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
|
||||
queries become a performance concern at scale.
|
||||
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
|
||||
third party with its own record), or is the role sufficient?
|
||||
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
|
||||
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
|
||||
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
|
||||
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
|
||||
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
|
||||
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
|
||||
wired into the regions table's row actions.
|
||||
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
|
||||
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
|
||||
all exist.
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 11 — Route & Sales Visit Management
|
||||
|
||||
**Status**: Partially Implemented
|
||||
**Status**: Not Started
|
||||
**Depends On**: 00 — Existing Gap Fixes
|
||||
|
||||
---
|
||||
@@ -62,41 +62,10 @@ access grant which may cover multiple users (e.g. a Supervisor overseeing severa
|
||||
## Open Questions
|
||||
|
||||
- Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it
|
||||
need actual distance/priority-based computation (external mapping API)? — **Deferred.** The
|
||||
endpoint is stubbed (`501 Not Implemented`) until this is decided.
|
||||
need actual distance/priority-based computation (external mapping API)?
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented: `assignOutletsToRoute`, `assignRouteToSalesRep`/`reassignRoute` (same endpoint),
|
||||
`recordRouteCheckIn`/`recordRouteCheckOut`, and `getStoreVisitHistory` (§4, now reads from
|
||||
`RouteVisitLog`). See [BACKEND_API_REFERENCE.md §6.6](../../sharedDocs/BACKEND_API_REFERENCE.md#66-routes)
|
||||
for full request/response shapes.
|
||||
|
||||
Deliberately deferred, not oversights:
|
||||
- **`optimizeRouteSequence`** — stubbed as `501 Not Implemented`; no distance/priority computation
|
||||
exists (see Open Questions above).
|
||||
- **`getRouteAdherence`** / **`getRoutePerformance`** — not built; the underlying metrics
|
||||
(adherence %, strike rate, missed-visit count) were never defined, so there was nothing concrete
|
||||
to implement against.
|
||||
- **`sendRouteDeviationAlert`** — check-in computes and stores a basic `IsDeviationFlagged` boolean
|
||||
(Haversine distance > 500m from the store's registered `Latitude`/`Longitude`) but does not call
|
||||
any alert/notification pipeline, since [09_fraud_controls.md](./09_fraud_controls.md) itself is
|
||||
not implemented yet.
|
||||
|
||||
Data model differs slightly from the original sketch above: `RouteId`/`OwnerUserId` became a
|
||||
nullable `FK_OwnerUserId` directly on `Route` (no separate ownership table needed for a
|
||||
single-owner field), and `Route Visit Log`'s `CheckInGps` became two decimal columns
|
||||
(`CheckInLatitude`/`CheckInLongitude`) to match the existing `Store.Latitude`/`Longitude` shape
|
||||
and make the distance calculation straightforward.
|
||||
|
||||
Authorization for the new endpoints follows the `RegionService.CanManageRegionAsync` pattern
|
||||
(scope check via `IScopeAccessService`, no dedicated `routes.*` permission code): bulk store
|
||||
assignment and owner assignment require `IScopeAccessService.HasRouteScopeAsync` plus role level
|
||||
≥ 20 (Supervisor+); check-in/check-out require route scope only, no role floor, since any rep with
|
||||
access to the route should be able to log their own visits.
|
||||
|
||||
Frontend: implemented in `distribution_fe` — an admin "Manage" dialog on the Routes page (owner +
|
||||
bulk store assignment), a read-only visit-history dialog on the Stores page, and a new rep-facing
|
||||
`/sales-visits` page for GPS check-in/check-out (see `distribution_fe/app/(dashboard)/sales-visits/`).
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
|
||||
@@ -107,7 +107,7 @@ One migration needed:
|
||||
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
|
||||
| GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
|
||||
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` |
|
||||
| GET | /api/stores/{id}/visit-history | stores.view | Wired to Step 11 — returns a plain array (not paginated) read from `RouteVisitLog` |
|
||||
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
|
||||
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme |
|
||||
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ and trigger wiring; the underlying conditions it watches for are already defined
|
||||
| `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. |
|
||||
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag |
|
||||
| — | `GET /api/notifications` — recipient's own notification inbox |
|
||||
| — | `PUT /api/notifications/{id}/read` |
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba
|
||||
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
|
||||
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
|
||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||
| 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) |
|
||||
| 11 | Route & Sales Visit Management | Partially Implemented | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
||||
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||
@@ -116,27 +116,27 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
|
||||
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/manage` (consolidated with the three rows below), backed by the `RegionalTarget` entity. Role-level restriction (`RegionalManager`+ with region scope, or `RegionalAgency` across any region) is temporarily disabled — see `10_region_management.md`. |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalBudget` entity. Same endpoint/access note as `setRegionalTarget`. |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role (this check remains active even with the role gate disabled). |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
|
||||
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. |
|
||||
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. |
|
||||
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
|
||||
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
|
||||
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
|
||||
|
||||
### 3. Route Management
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. |
|
||||
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Implemented — `POST /api/routes/{id}/stores` bulk-replaces `FK_RouteId` for the given store ids (all-or-nothing; 404 if any id doesn't exist). |
|
||||
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Implemented — `PUT /api/routes/{id}/owner`, backed by the new `Route.FK_OwnerUserId` field, distinct from the existing `UserRouteScope` grant. |
|
||||
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Stubbed — `POST /api/routes/{id}/optimize` always returns `501 Not Implemented`; no distance/priority computation exists yet (see open question in [11_route_sales_management.md](./11_route_sales_management.md)). |
|
||||
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Implemented — `POST /api/routes/{id}/visits/check-in`, backed by the new `RouteVisitLog` entity. Sets `IsDeviationFlagged` via a basic Haversine distance check (>500m) against the store's `Latitude`/`Longitude`; does not yet call `sendRouteDeviationAlert` (§9, unimplemented). |
|
||||
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Implemented — `POST /api/routes/{id}/visits/check-out`, closes the matching open `RouteVisitLog` row (404 if none). |
|
||||
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — metrics deferred, out of scope for the current pass. |
|
||||
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Implemented — same endpoint as `assignRouteToSalesRep` (`PUT /api/routes/{id}/owner`); the doc treats these as one action, distinguished only by caller intent. |
|
||||
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented — metrics deferred, out of scope for the current pass. |
|
||||
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
|
||||
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
|
||||
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
|
||||
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
|
||||
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Not implemented. |
|
||||
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Not implemented. |
|
||||
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — depends on check-in/check-out above. |
|
||||
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Not implemented. |
|
||||
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented. |
|
||||
| `planDeliveryRoute(warehouseId, orderIds[])` | Builds the warehouse-to-outlet dispatch route (separate from the sales visit route). | Not implemented — this is the `DeliveryRun` concept; see [03_ride_lifecycle.md](./03_ride_lifecycle.md). |
|
||||
|
||||
### 4. Store (Outlet) Management
|
||||
@@ -152,7 +152,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
|
||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Implemented — `GET /api/stores/{id}/visit-history` now reads from the `RouteVisitLog` table (§3), returning each visit's date, rep, and outcome (`In Progress`/`Completed`). |
|
||||
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
|
||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). |
|
||||
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. |
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Test Bugs & Change Tracker
|
||||
|
||||
Running log of backend API failures found during testing and the corresponding frontend changes needed. Add one entry per issue found; move to "Resolved" once fixed and verified.
|
||||
|
||||
## Open
|
||||
|
||||
| # | Area | Backend API Failure | Frontend Change Needed | Date |
|
||||
|---|------|----------------------|--------------------------|------|
|
||||
| 1 | Operations → Rides (Create Ride) | On create (not save) | Add a View button to the action column in Operations → Rides table | 2026-09-01 |
|
||||
| 2 | Operations → Loading Sheets | | Add a View button to the action column in Operations → Loading Sheets table | 2026-09-01 |
|
||||
| 3 | Sales (Create Sale) | Sale create not working | Update the Create Sale modal UI|Add view button to the action | 2026-09-01 |
|
||||
| 4 | Orders | | Add a View button to the action column in Orders table | 2026-09-01 |
|
||||
| 5 | Payments (Record Payment) | Cannot add new payment | Add a View button to the action column in Payments table | 2026-09-01 |
|
||||
|
||||
## Resolved
|
||||
|
||||
| # | Area | Backend API Failure | Frontend Change Needed | Fixed In | Date |
|
||||
|---|------|----------------------|--------------------------|----------|------|
|
||||
| | | | | | |
|
||||
-270
@@ -1,270 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RegionResponse, UserResponse } from '@/types/api'
|
||||
|
||||
function currentPeriod() {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
regionId: z.string().min(1, 'Region is required'),
|
||||
period: z.string(),
|
||||
targetValue: z.string(),
|
||||
budgetAmount: z.string(),
|
||||
ruleConfig: z.string(),
|
||||
agencyUserId: z.string(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export interface RegionManageInitialValues {
|
||||
regionId: string
|
||||
period?: string
|
||||
targetValue?: number
|
||||
budgetAmount?: number
|
||||
ruleConfig?: string
|
||||
agencyUserId?: string
|
||||
}
|
||||
|
||||
interface RegionManageEntrySheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
initial?: RegionManageInitialValues | null
|
||||
}
|
||||
|
||||
export function RegionManageEntrySheet({ open, onClose, onSuccess, initial }: RegionManageEntrySheetProps) {
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
|
||||
const isEdit = !!initial
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
regionId: '',
|
||||
period: currentPeriod(),
|
||||
targetValue: '',
|
||||
budgetAmount: '',
|
||||
ruleConfig: '',
|
||||
agencyUserId: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
form.reset({
|
||||
regionId: initial?.regionId ?? '',
|
||||
period: initial?.period ?? currentPeriod(),
|
||||
targetValue: initial?.targetValue != null ? String(initial.targetValue) : '',
|
||||
budgetAmount: initial?.budgetAmount != null ? String(initial.budgetAmount) : '',
|
||||
ruleConfig: initial?.ruleConfig ?? '',
|
||||
agencyUserId: initial?.agencyUserId ?? '',
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
regionsApi.list({ page: 1, pageSize: 200 }),
|
||||
usersApi.list({ page: 1, pageSize: 200 }),
|
||||
])
|
||||
.then(([r, u]) => {
|
||||
setRegionsList(getPaginatedItems(r).filter((x) => x.isActive))
|
||||
setAgencyUsers(getPaginatedItems(u).filter((x) => x.roleName === 'RegionalAgency'))
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||
}, [open, initial])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const body: {
|
||||
period?: string
|
||||
targetValue?: number
|
||||
budgetAmount?: number
|
||||
agencyUserId?: string
|
||||
ruleConfig?: string
|
||||
} = {}
|
||||
|
||||
const target = values.targetValue.trim() ? parseFloat(values.targetValue) : NaN
|
||||
const budget = values.budgetAmount.trim() ? parseFloat(values.budgetAmount) : NaN
|
||||
if (!Number.isNaN(target)) {
|
||||
body.period = values.period
|
||||
body.targetValue = target
|
||||
}
|
||||
if (!Number.isNaN(budget)) {
|
||||
body.period = values.period
|
||||
body.budgetAmount = budget
|
||||
}
|
||||
if (values.ruleConfig.trim()) body.ruleConfig = values.ruleConfig
|
||||
if (values.agencyUserId) body.agencyUserId = values.agencyUserId
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
toast.add({ title: 'Nothing to save', description: 'Fill in at least one field.', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await regionsApi.manage(values.regionId, body)
|
||||
toast.add({ title: isEdit ? 'Region settings updated' : 'Entry saved', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save entry', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Manage Entry' : 'Add Manage Entry'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={isEdit}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder="Select a region">
|
||||
{regionsList.find((r) => r.id === field.value)?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
label="Period"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="month"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetValue"
|
||||
label="Regional Target"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. 500000"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budgetAmount"
|
||||
label="Regional Budget"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. 200000"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ruleConfig"
|
||||
label="Regional Rule Config"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
placeholder='0.15'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agencyUserId"
|
||||
label="Map Regional Agency"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an agency user">
|
||||
{agencyUsers.find((u) => u.id === field.value)?.fullName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agencyUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{agencyUsers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RegionManageEntryResponse, UserResponse } from '@/types/api'
|
||||
|
||||
import { RegionManageEntrySheet, type RegionManageInitialValues } from './region-manage-entry-sheet'
|
||||
|
||||
interface RegionManageRow {
|
||||
regionId: string
|
||||
regionName: string
|
||||
period: string | null
|
||||
targetValue: number | null
|
||||
budgetAmount: number | null
|
||||
ruleConfig: string | null
|
||||
agencyUserIds: string[]
|
||||
entries: RegionManageEntryResponse[]
|
||||
}
|
||||
|
||||
function groupByRegion(entries: RegionManageEntryResponse[]): RegionManageRow[] {
|
||||
const byRegion = new Map<string, RegionManageEntryResponse[]>()
|
||||
for (const e of entries) {
|
||||
const list = byRegion.get(e.regionId) ?? []
|
||||
list.push(e)
|
||||
byRegion.set(e.regionId, list)
|
||||
}
|
||||
|
||||
const latestOf = (list: RegionManageEntryResponse[], type: RegionManageEntryResponse['entryType']) =>
|
||||
list
|
||||
.filter((e) => e.entryType === type)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
|
||||
|
||||
return Array.from(byRegion.entries()).map(([regionId, list]) => {
|
||||
const target = latestOf(list, 'Target')
|
||||
const budget = latestOf(list, 'Budget')
|
||||
const taxRule = latestOf(list, 'TaxRule')
|
||||
const agencyMaps = list.filter((e) => e.entryType === 'AgencyMap')
|
||||
|
||||
return {
|
||||
regionId,
|
||||
regionName: list[0].regionName,
|
||||
period: target?.period ?? budget?.period ?? null,
|
||||
targetValue: target?.amount ?? null,
|
||||
budgetAmount: budget?.amount ?? null,
|
||||
ruleConfig: taxRule?.ruleConfig ?? null,
|
||||
agencyUserIds: agencyMaps.map((m) => m.agencyUserId).filter((id): id is string => !!id),
|
||||
entries: list,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function RegionManageListClient() {
|
||||
const [entries, setEntries] = useState<RegionManageEntryResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editingInitial, setEditingInitial] = useState<RegionManageInitialValues | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await regionsApi.manageEntries()
|
||||
setEntries(res)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load manage entries', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setAgencyUsers(getPaginatedItems(res)))
|
||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => groupByRegion(entries), [entries])
|
||||
|
||||
function openAdd() {
|
||||
setEditingInitial(null)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(row: RegionManageRow) {
|
||||
setEditingInitial({
|
||||
regionId: row.regionId,
|
||||
period: row.period ?? undefined,
|
||||
targetValue: row.targetValue ?? undefined,
|
||||
budgetAmount: row.budgetAmount ?? undefined,
|
||||
ruleConfig: row.ruleConfig ?? undefined,
|
||||
agencyUserId: row.agencyUserIds[0] ?? undefined,
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
async function handleClear(row: RegionManageRow) {
|
||||
try {
|
||||
await Promise.all(
|
||||
row.entries.map((e) => regionsApi.removeManageEntry(e.entryType, e.id))
|
||||
)
|
||||
toast.add({ title: 'Region settings cleared', type: 'success' })
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to clear settings', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const tableConfig = useMemo<TableConfig<RegionManageRow>>(() => ({
|
||||
rowKey: 'regionId',
|
||||
stickyHeader: true,
|
||||
emptyMessage: 'No manage entries yet. Add one to get started.',
|
||||
pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] },
|
||||
columns: [
|
||||
{
|
||||
key: 'regionName',
|
||||
title: 'Region',
|
||||
fields: [{ key: 'regionName', primary: true }],
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
key: 'period',
|
||||
title: 'Period',
|
||||
fields: ['period'],
|
||||
width: 100,
|
||||
cellRender: (row) => row.period ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'targetValue',
|
||||
title: 'Regional Target',
|
||||
fields: ['targetValue'],
|
||||
width: 140,
|
||||
cellRender: (row) => row.targetValue ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'budgetAmount',
|
||||
title: 'Regional Budget',
|
||||
fields: ['budgetAmount'],
|
||||
width: 140,
|
||||
cellRender: (row) => row.budgetAmount ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'ruleConfig',
|
||||
title: 'Regional Rule Config',
|
||||
fields: ['ruleConfig'],
|
||||
minWidth: 180,
|
||||
cellRender: (row) => row.ruleConfig ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'agencyUserIds',
|
||||
title: 'Map Regional Agency',
|
||||
fields: ['agencyUserIds'],
|
||||
minWidth: 180,
|
||||
cellRender: (row) => {
|
||||
if (row.agencyUserIds.length === 0) return '—'
|
||||
return row.agencyUserIds
|
||||
.map((id) => agencyUsers.find((u) => u.id === id)?.fullName ?? id)
|
||||
.join(', ')
|
||||
},
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit region settings',
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
action: 'deactivate',
|
||||
label: 'Clear region settings',
|
||||
onClick: handleClear,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [agencyUsers])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Region Manage</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Targets, budgets, tax rules, and agency mappings across regions
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Entry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<RegionManageEntrySheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={fetchData}
|
||||
initial={editingInitial}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { RegionManageListClient } from './_components/region-manage-list-client'
|
||||
|
||||
export default function RegionManagePage() {
|
||||
return <RegionManageListClient />
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { routes as routesApi, stores as storesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RouteResponse, UserResponse, StoreResponse } from '@/types/api'
|
||||
|
||||
interface RouteManageSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
route: RouteResponse | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManageSheetProps) {
|
||||
const [salesReps, setSalesReps] = useState<UserResponse[]>([])
|
||||
const [ownerUserId, setOwnerUserId] = useState('')
|
||||
const [allStores, setAllStores] = useState<StoreResponse[]>([])
|
||||
const [checkedStoreIds, setCheckedStoreIds] = useState<Set<string>>(new Set())
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !route) return
|
||||
|
||||
setOwnerUserId(route.ownerUserId ?? '')
|
||||
setStoreFilter('')
|
||||
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep')))
|
||||
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
|
||||
|
||||
storesApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
const items = getPaginatedItems(res)
|
||||
setAllStores(items)
|
||||
setCheckedStoreIds(new Set(items.filter((s) => s.routeId === route.id).map((s) => s.id)))
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load stores', type: 'error' }))
|
||||
}, [open, route])
|
||||
|
||||
if (!route) return null
|
||||
|
||||
async function handleAssignOwner() {
|
||||
if (!ownerUserId) return
|
||||
setSaving('owner')
|
||||
try {
|
||||
await routesApi.assignOwner(route!.id, ownerUserId)
|
||||
toast.add({ title: 'Route owner assigned', type: 'success' })
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to assign owner', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveStores() {
|
||||
setSaving('stores')
|
||||
try {
|
||||
await routesApi.assignStores(route!.id, Array.from(checkedStoreIds))
|
||||
toast.add({ title: 'Store assignment saved', type: 'success' })
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to assign stores', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStore(storeId: string) {
|
||||
setCheckedStoreIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(storeId)) next.delete(storeId)
|
||||
else next.add(storeId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredStores = allStores.filter((s) =>
|
||||
s.storeName.toLowerCase().includes(storeFilter.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage {route.routeName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a sales rep owner and the stores on this route.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Route Owner (Sales Rep)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={ownerUserId} onValueChange={(v) => setOwnerUserId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a sales rep">
|
||||
{salesReps.find((u) => u.id === ownerUserId)?.fullName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salesReps.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" onClick={handleAssignOwner} disabled={saving === 'owner'}>
|
||||
{saving === 'owner' ? 'Saving…' : 'Assign'}
|
||||
</Button>
|
||||
</div>
|
||||
{salesReps.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No users with the SalesRep role found.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Stores on this Route</Label>
|
||||
<Input
|
||||
placeholder="Filter stores…"
|
||||
value={storeFilter}
|
||||
onChange={(e) => setStoreFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto rounded-md border p-2">
|
||||
{filteredStores.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">No stores match.</p>
|
||||
)}
|
||||
{filteredStores.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 py-1 px-1 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checkedStoreIds.has(s.id)}
|
||||
onCheckedChange={() => toggleStore(s.id)}
|
||||
/>
|
||||
{s.storeName}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" onClick={handleSaveStores} disabled={saving === 'stores'} className="self-start">
|
||||
{saving === 'stores' ? 'Saving…' : 'Save Assignment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,10 @@ 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 { routes as routesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RouteResponse } from '@/types/api'
|
||||
|
||||
import { RouteSheet } from './route-sheet'
|
||||
import { RouteManageSheet } from './route-manage-sheet'
|
||||
|
||||
export function RoutesClient() {
|
||||
const [data, setData] = useState<RouteResponse[]>([])
|
||||
@@ -21,12 +20,9 @@ export function RoutesClient() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [userNameById, setUserNameById] = useState<Record<string, string>>({})
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<RouteResponse | null>(null)
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
const [managing, setManaging] = useState<RouteResponse | null>(null)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -45,16 +41,6 @@ export function RoutesClient() {
|
||||
fetchData(page, pageSize)
|
||||
}, [fetchData, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const u of getPaginatedItems(res)) map[u.id] = u.fullName
|
||||
setUserNameById(map)
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setSheetOpen(true)
|
||||
@@ -65,11 +51,6 @@ export function RoutesClient() {
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openManage(route: RouteResponse) {
|
||||
setManaging(route)
|
||||
setManageOpen(true)
|
||||
}
|
||||
|
||||
async function toggleActive(route: RouteResponse) {
|
||||
try {
|
||||
await routesApi.update(route.id, { isActive: !route.isActive })
|
||||
@@ -114,17 +95,6 @@ export function RoutesClient() {
|
||||
fields: [{ key: 'length', transform: (v) => `${v} km` }],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
title: 'Owner',
|
||||
fields: [{ key: 'ownerUserId' }],
|
||||
minWidth: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.ownerUserId ? userNameById[row.ownerUserId] ?? 'Unknown' : 'Unassigned'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
@@ -155,11 +125,6 @@ export function RoutesClient() {
|
||||
label: 'Edit route',
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
action: 'confirm',
|
||||
label: 'Manage owner & stores',
|
||||
onClick: openManage,
|
||||
},
|
||||
{
|
||||
action: 'deactivate',
|
||||
label: 'Deactivate route',
|
||||
@@ -174,7 +139,7 @@ export function RoutesClient() {
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize, userNameById, openEdit, openManage, toggleActive])
|
||||
}), [pageSize, openEdit, toggleActive])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -208,13 +173,6 @@ export function RoutesClient() {
|
||||
route={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
|
||||
<RouteManageSheet
|
||||
open={manageOpen}
|
||||
onClose={() => setManageOpen(false)}
|
||||
route={managing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
routes as routesApi,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api'
|
||||
|
||||
@@ -50,6 +49,9 @@ interface RideSheetProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
|
||||
return Array.isArray(value?.items) ? value.items : []
|
||||
}
|
||||
|
||||
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
|
||||
@@ -89,10 +91,10 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([v, d, r, w]) => {
|
||||
setVehiclesList(getPaginatedItems(v))
|
||||
setDriversList(getPaginatedItems(d))
|
||||
setRoutesList(getPaginatedItems(r))
|
||||
setWarehousesList(getPaginatedItems(w))
|
||||
setVehiclesList(ensureItems(v))
|
||||
setDriversList(ensureItems(d))
|
||||
setRoutesList(ensureItems(r))
|
||||
setWarehousesList(ensureItems(w))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DashboardCanvas } from '@/components/dashboard/dashboard-canvas'
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome to the Distribution ERP.</p>
|
||||
</div>
|
||||
)
|
||||
return <DashboardCanvas />
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { GrnResponse } from '@/types/api'
|
||||
import { GrnSheet } from './grn-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -22,7 +19,6 @@ export function GrnClient() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -105,10 +101,6 @@ export function GrnClient() {
|
||||
Stock received into a warehouse against a purchase order
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
Record Receipt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -121,12 +113,6 @@ export function GrnClient() {
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<GrnSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFieldArray } from 'react-hook-form'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
grn as grnApi,
|
||||
purchaseOrders as purchaseOrdersApi,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { PurchaseOrderResponse, WarehouseResponse } from '@/types/api'
|
||||
|
||||
const grnItemSchema = z.object({
|
||||
purchaseOrderItemId: z.string().min(1),
|
||||
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
purchaseOrderId: z.string().min(1, 'Purchase order is required'),
|
||||
warehouseId: z.string().min(1, 'Warehouse is required'),
|
||||
receivedAt: z.string().min(1, 'Received date is required'),
|
||||
items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
interface GrnSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
||||
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
|
||||
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
|
||||
const [loadingPO, setLoadingPO] = useState(false)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
purchaseOrderId: '',
|
||||
warehouseId: '',
|
||||
receivedAt: todayIso(),
|
||||
items: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
|
||||
|
||||
// Load reference data when sheet opens
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), items: [] })
|
||||
setSelectedPO(null)
|
||||
Promise.all([
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([pos, ws]) => {
|
||||
// Only POs that can still receive goods
|
||||
const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
|
||||
setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
|
||||
setWarehousesList(getPaginatedItems(ws))
|
||||
})
|
||||
.catch(err =>
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
}, [open])
|
||||
|
||||
// When PO changes, fetch its details and populate items
|
||||
async function handlePOChange(poId: string) {
|
||||
form.setValue('purchaseOrderId', poId, { shouldValidate: true })
|
||||
if (!poId) {
|
||||
setSelectedPO(null)
|
||||
replace([])
|
||||
return
|
||||
}
|
||||
setLoadingPO(true)
|
||||
try {
|
||||
const po = await purchaseOrdersApi.get(poId)
|
||||
setSelectedPO(po)
|
||||
// Pre-fill items: remaining quantity for each non-cancelled line
|
||||
const lineItems = (po.items ?? [])
|
||||
.filter(item => !item.isCancelled)
|
||||
.map(item => ({
|
||||
purchaseOrderItemId: item.id,
|
||||
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
|
||||
rejectedQuantity: 0,
|
||||
}))
|
||||
replace(lineItems)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoadingPO(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await grnApi.create({
|
||||
purchaseOrderId: values.purchaseOrderId,
|
||||
warehouseId: values.warehouseId,
|
||||
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
|
||||
items: values.items,
|
||||
})
|
||||
toast.add({ title: 'Goods receipt recorded', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Goods Receipt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record stock received into a warehouse against a purchase order.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="purchaseOrderId"
|
||||
label="Purchase Order"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={v => handlePOChange(v ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select purchase order..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligiblePOs.map(po => (
|
||||
<SelectItem key={po.id} value={po.id}>
|
||||
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="warehouseId"
|
||||
label="Warehouse"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select warehouse..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehousesList.map(w => (
|
||||
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="receivedAt"
|
||||
label="Received Date"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{/* Line items — populated from the selected PO */}
|
||||
{selectedPO && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-foreground">Line Items</p>
|
||||
|
||||
{loadingPO ? (
|
||||
<p className="text-sm text-muted-foreground">Loading items…</p>
|
||||
) : poItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No receivable items on this order.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-[1fr_100px_100px] gap-3 px-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Item</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Accepted</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Rejected</p>
|
||||
</div>
|
||||
|
||||
{fields.map((f, index) => {
|
||||
const poItem = poItems[index]
|
||||
return (
|
||||
<div key={f.id} className="grid grid-cols-[1fr_100px_100px] items-start gap-3 rounded-xl border px-3 py-3">
|
||||
<div className="flex flex-col gap-0.5 pt-1">
|
||||
<span className="text-sm font-medium">{poItem?.itemName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.acceptedQuantity`}
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.rejectedQuantity`}
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
-262
@@ -1,262 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useFieldArray } from 'react-hook-form'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||
import {
|
||||
purchaseOrders as purchaseOrdersApi,
|
||||
suppliers as suppliersApi,
|
||||
items as itemsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { ItemSummaryResponse, SupplierResponse } from '@/types/api'
|
||||
|
||||
const poItemSchema = z.object({
|
||||
itemId: z.string().min(1, 'Item is required'),
|
||||
quantity: z.number().min(1, 'Quantity must be at least 1'),
|
||||
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
supplierId: z.string().min(1, 'Supplier is required'),
|
||||
items: z.array(poItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
interface PurchaseOrderSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function defaultItem() {
|
||||
return { itemId: '', quantity: 1, unitCost: 0 }
|
||||
}
|
||||
|
||||
export function PurchaseOrderSheet({ open, onClose, onSuccess }: PurchaseOrderSheetProps) {
|
||||
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { supplierId: '', items: [defaultItem()] },
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
|
||||
const watchedItems = form.watch('items')
|
||||
const itemsById = useMemo(() => new Map(itemsList.map(i => [i.id, i])), [itemsList])
|
||||
|
||||
const totalCost = useMemo(
|
||||
() => watchedItems.reduce((sum, i) => sum + (Number(i.quantity) || 0) * (Number(i.unitCost) || 0), 0),
|
||||
[watchedItems]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ supplierId: '', items: [defaultItem()] })
|
||||
Promise.all([
|
||||
suppliersApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([s, it]) => {
|
||||
setSuppliersList(getPaginatedItems(s))
|
||||
setItemsList(getPaginatedItems(it))
|
||||
})
|
||||
.catch(err =>
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
}, [open])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await purchaseOrdersApi.create(values)
|
||||
toast.add({ title: 'Purchase order created', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to create purchase order', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Purchase Order</DialogTitle>
|
||||
<DialogDescription>Issue a new purchase order to a supplier.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Left — supplier + summary */}
|
||||
<FieldGroup className="lg:col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplierId"
|
||||
label="Supplier"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select supplier..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{suppliersList.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Order Summary</p>
|
||||
<p className="text-sm text-muted-foreground">{fields.length} line item{fields.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total Cost</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
Rs. {totalCost.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{/* Right — scrollable items */}
|
||||
<div className="flex min-h-0 flex-col rounded-2xl border p-4 lg:col-span-1">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Line Items</p>
|
||||
<p className="text-sm text-muted-foreground">Add items to order</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => append(defaultItem())}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
{fields.map((f, index) => {
|
||||
const selected = itemsById.get(watchedItems[index]?.itemId ?? '')
|
||||
return (
|
||||
<div key={f.id} className="space-y-3 rounded-2xl border p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.itemId`}
|
||||
label="Item"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={val => {
|
||||
field.onChange(val ?? '')
|
||||
const item = val ? itemsById.get(val) : undefined
|
||||
if (item) form.setValue(`items.${index}.unitCost`, item.salePrice, { shouldValidate: true })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select item..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemsList.map(i => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.name} — {i.sku}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.quantity`}
|
||||
label="Qty"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.unitCost`}
|
||||
label="Unit Cost"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="0.01"
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<p className="text-xs text-muted-foreground">UOM: {selected.baseUomName}</p>
|
||||
)}
|
||||
|
||||
{fields.length > 1 && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => remove(index)}>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Creating…' : 'Create Order'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
-15
@@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
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 { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
|
||||
import { PurchaseOrderSheet } from './purchase-order-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -31,7 +27,6 @@ export function PurchaseOrdersClient() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -135,10 +130,6 @@ export function PurchaseOrdersClient() {
|
||||
Orders placed with suppliers to restock inventory
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
New Order
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -151,12 +142,6 @@ export function PurchaseOrdersClient() {
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<PurchaseOrderSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { routes as routesApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RouteResponse, StoreResponse, RouteVisitResponse } from '@/types/api'
|
||||
|
||||
type VisitState = 'idle' | 'checked-in' | 'checked-out'
|
||||
|
||||
function getCurrentPosition(): Promise<GeolocationPosition> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
reject(new Error('Geolocation is not supported by this browser'))
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 10000 })
|
||||
})
|
||||
}
|
||||
|
||||
export function SalesVisitsClient() {
|
||||
const [routeList, setRouteList] = useState<RouteResponse[]>([])
|
||||
const [selectedRouteId, setSelectedRouteId] = useState('')
|
||||
const [storeList, setStoreList] = useState<StoreResponse[]>([])
|
||||
const [loadingStores, setLoadingStores] = useState(false)
|
||||
const [visitState, setVisitState] = useState<Record<string, VisitState>>({})
|
||||
const [deviationByStore, setDeviationByStore] = useState<Record<string, boolean>>({})
|
||||
const [busyStoreId, setBusyStoreId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
routesApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
|
||||
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRouteId) {
|
||||
setStoreList([])
|
||||
return
|
||||
}
|
||||
setLoadingStores(true)
|
||||
storesApi.list({ routeId: selectedRouteId, page: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
setStoreList(getPaginatedItems(res).filter((s) => s.isActive))
|
||||
setVisitState({})
|
||||
setDeviationByStore({})
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load stores', type: 'error' }))
|
||||
.finally(() => setLoadingStores(false))
|
||||
}, [selectedRouteId])
|
||||
|
||||
async function handleCheckIn(store: StoreResponse) {
|
||||
setBusyStoreId(store.id)
|
||||
try {
|
||||
const position = await getCurrentPosition()
|
||||
const result: RouteVisitResponse = await routesApi.checkIn(selectedRouteId, {
|
||||
storeId: store.id,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
})
|
||||
setVisitState((prev) => ({ ...prev, [store.id]: 'checked-in' }))
|
||||
setDeviationByStore((prev) => ({ ...prev, [store.id]: result.isDeviationFlagged }))
|
||||
toast.add({
|
||||
title: 'Checked in',
|
||||
description: result.isDeviationFlagged ? 'Location does not match the store’s registered address.' : undefined,
|
||||
type: result.isDeviationFlagged ? 'error' : 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error && !getApiError(err) ? err.message : getApiError(err)
|
||||
toast.add({ title: 'Check-in failed', description: message, type: 'error' })
|
||||
} finally {
|
||||
setBusyStoreId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckOut(store: StoreResponse) {
|
||||
setBusyStoreId(store.id)
|
||||
try {
|
||||
await routesApi.checkOut(selectedRouteId, { storeId: store.id })
|
||||
setVisitState((prev) => ({ ...prev, [store.id]: 'checked-out' }))
|
||||
toast.add({ title: 'Checked out', type: 'success' })
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Check-out failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusyStoreId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sales Visits</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Check in and out of scheduled store visits along a route
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 max-w-sm">
|
||||
<Label>Route</Label>
|
||||
<Select value={selectedRouteId} onValueChange={(v) => setSelectedRouteId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a route">
|
||||
{routeList.find((r) => r.id === selectedRouteId)?.routeName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routeList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loadingStores && <p className="text-sm text-muted-foreground">Loading stores…</p>}
|
||||
|
||||
{!loadingStores && selectedRouteId && storeList.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No active stores on this route.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{storeList.map((store) => {
|
||||
const state = visitState[store.id] ?? 'idle'
|
||||
const isBusy = busyStoreId === store.id
|
||||
return (
|
||||
<div key={store.id} className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{store.storeName}</span>
|
||||
<span className="text-xs text-muted-foreground">{store.storeAddress}</span>
|
||||
{deviationByStore[store.id] && state !== 'idle' && (
|
||||
<Badge variant="destructive" className="w-fit mt-1">Location mismatch</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === 'checked-out' && <Badge variant="secondary">Completed</Badge>}
|
||||
{state !== 'checked-out' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={state === 'checked-in' ? 'outline' : 'default'}
|
||||
disabled={isBusy || state === 'checked-in'}
|
||||
onClick={() => handleCheckIn(store)}
|
||||
>
|
||||
{isBusy && state === 'idle' ? 'Checking in…' : state === 'checked-in' ? 'Checked In' : 'Check In'}
|
||||
</Button>
|
||||
)}
|
||||
{state === 'checked-in' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleCheckOut(store)}
|
||||
>
|
||||
{isBusy ? 'Checking out…' : 'Check Out'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SalesVisitsClient } from './_components/sales-visits-client'
|
||||
|
||||
export default function SalesVisitsPage() {
|
||||
return <SalesVisitsClient />
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
rides as ridesApi,
|
||||
items as itemsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api'
|
||||
|
||||
@@ -55,6 +54,9 @@ interface SaleSheetProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
|
||||
return Array.isArray(value?.items) ? value.items : []
|
||||
}
|
||||
|
||||
function createDefaultItem() {
|
||||
return {
|
||||
@@ -110,9 +112,9 @@ export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) {
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([stores, rides, items]) => {
|
||||
setStoresList(getPaginatedItems(stores))
|
||||
setRidesList(getPaginatedItems(rides))
|
||||
setItemsList(getPaginatedItems(items))
|
||||
setStoresList(ensureItems(stores))
|
||||
setRidesList(ensureItems(rides))
|
||||
setItemsList(ensureItems(items))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { StoreResponse, RouteResponse } from '@/types/api'
|
||||
|
||||
const LocationPicker = dynamic(
|
||||
() => import('../../_components/location-picker').then((m) => m.LocationPicker),
|
||||
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 520 }} /> }
|
||||
)
|
||||
|
||||
const schema = z.object({
|
||||
storeName: z.string().min(1, 'Store name is required'),
|
||||
ownerName: z.string().min(1, 'Owner name is required'),
|
||||
storeAddress: z.string().min(1, 'Store address is required'),
|
||||
routeId: z.string().min(1, 'Route is required'),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
creditLimit: z.number().min(0),
|
||||
segment: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
// ─── Inner form — only mounts once store data is available ───────────────────
|
||||
|
||||
interface StoreEditFormProps {
|
||||
store: StoreResponse
|
||||
routes: RouteResponse[]
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function StoreEditForm({ store, routes, onSaved, onCancel }: StoreEditFormProps) {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
storeName: store.storeName,
|
||||
ownerName: store.ownerName,
|
||||
storeAddress: store.storeAddress,
|
||||
routeId: store.routeId,
|
||||
latitude: store.latitude,
|
||||
longitude: store.longitude,
|
||||
creditLimit: store.creditLimit,
|
||||
segment: store.segment ?? '',
|
||||
isActive: store.isActive,
|
||||
},
|
||||
})
|
||||
|
||||
const lat = form.watch('latitude')
|
||||
const lng = form.watch('longitude')
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await storesApi.update(store.id, {
|
||||
...values,
|
||||
segment: values.segment || undefined,
|
||||
})
|
||||
toast.add({ title: 'Store updated', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update store', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Left — text fields */}
|
||||
<div className="lg:col-span-1">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeName"
|
||||
label="Store Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="storeName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Sunrise Mart"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ownerName"
|
||||
label="Owner Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="ownerName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Kamal Perera"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeAddress"
|
||||
label="Store Address"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="storeAddress"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. 123 Main St, Colombo"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routeId"
|
||||
label="Route"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a route" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routes.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.routeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="creditLimit"
|
||||
label="Credit Limit"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="creditLimit"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
value={String(field.value as number)}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="segment"
|
||||
label="Segment"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="segment"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Retail"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
{/* Right — map */}
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Store Location <span className="text-destructive">*</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click the map or drag the pin to update the location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LocationPicker
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
height={520}
|
||||
onChange={(newLat, newLng) => {
|
||||
form.setValue('latitude', newLat, { shouldValidate: true })
|
||||
form.setValue('longitude', newLng, { shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
|
||||
{(lat !== 0 || lng !== 0) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page — fetches data, then hands off to the form ─────────────────────────
|
||||
|
||||
export default function EditStorePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [store, setStore] = useState<StoreResponse | null>(null)
|
||||
const [routes, setRoutes] = useState<RouteResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
storesApi.get(id),
|
||||
routesApi.list({ pageSize: 100 }),
|
||||
])
|
||||
.then(([s, r]) => {
|
||||
setStore(s)
|
||||
setRoutes(getPaginatedItems(r))
|
||||
})
|
||||
.catch((err) =>
|
||||
toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/stores')}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Edit Store</h1>
|
||||
<p className="text-sm text-muted-foreground">Update the store details below.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton while loading */}
|
||||
{loading && (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse rounded-2xl bg-muted lg:col-span-2" style={{ height: 520 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form — only mounts after store data is available */}
|
||||
{!loading && store && (
|
||||
<StoreEditForm
|
||||
store={store}
|
||||
routes={routes}
|
||||
onSaved={() => router.push('/stores')}
|
||||
onCancel={() => router.push('/stores')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stores as storesApi, getApiError } from '@/lib/api/index'
|
||||
import type { StoreResponse, KycStatus } from '@/types/api'
|
||||
|
||||
const LocationPicker = dynamic(
|
||||
() => import('../_components/location-picker').then((m) => m.LocationPicker),
|
||||
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
|
||||
)
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
|
||||
const fmtMoney = (v: number) =>
|
||||
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
||||
|
||||
function kycVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
|
||||
if (status === 'Verified') return 'default'
|
||||
if (status === 'Rejected') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-3 border-b last:border-0">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium text-foreground">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StoreViewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [store, setStore] = useState<StoreResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
storesApi.get(id)
|
||||
.then(setStore)
|
||||
.catch((err) =>
|
||||
toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={() => router.push('/stores')}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="h-7 w-48 animate-pulse rounded-lg bg-muted" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{store?.storeName}</h1>
|
||||
{store && (
|
||||
<>
|
||||
<Badge variant={store.isActive ? 'default' : 'secondary'}>
|
||||
{store.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Store details and location</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => router.push(`/stores/${id}/edit`)}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
|
||||
Edit Store
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body skeleton */}
|
||||
{loading && (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-2 lg:col-span-1">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="h-12 animate-pulse rounded-xl bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-20 animate-pulse rounded-2xl bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{!loading && store && (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Left — store details */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="rounded-2xl border px-4">
|
||||
<DetailRow label="Owner">{store.ownerName}</DetailRow>
|
||||
<DetailRow label="Address">{store.storeAddress}</DetailRow>
|
||||
<DetailRow label="Route ID">
|
||||
<span className="font-mono text-xs">{store.routeId}</span>
|
||||
</DetailRow>
|
||||
{store.segment && (
|
||||
<DetailRow label="Segment">{store.segment}</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
|
||||
<DetailRow label="KYC Status">
|
||||
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
|
||||
</DetailRow>
|
||||
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — credit stats + map */}
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="Credit Limit"
|
||||
value={fmtMoney(store.creditLimit)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Outstanding"
|
||||
value={fmtMoney(store.outstandingBalance)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Available Credit"
|
||||
value={fmtMoney(availableCredit)}
|
||||
sub={availableCredit < 0 ? 'Over limit' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LocationPicker
|
||||
lat={store.latitude}
|
||||
lng={store.longitude}
|
||||
height={460}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Circle, CircleMarker, useMapEvents, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const pinIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
})
|
||||
|
||||
interface UserLocation {
|
||||
lat: number
|
||||
lng: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
interface LocationPickerProps {
|
||||
lat: number
|
||||
lng: number
|
||||
onChange?: (lat: number, lng: number) => void
|
||||
height?: number
|
||||
readOnly?: boolean
|
||||
showUserLocation?: boolean
|
||||
}
|
||||
|
||||
function ClickHandler({ onChange }: { onChange: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
onChange(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Recenters the map once when coords first become non-zero (e.g. after geolocation resolves)
|
||||
function AutoCenter({ lat, lng }: { lat: number; lng: number }) {
|
||||
const map = useMap()
|
||||
const applied = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!applied.current && (lat !== 0 || lng !== 0)) {
|
||||
applied.current = true
|
||||
map.setView([lat, lng], 15)
|
||||
}
|
||||
}, [lat, lng, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Sri Lanka geographic center — fallback when no coords are available yet
|
||||
const SRI_LANKA: [number, number] = [7.8731, 80.7718]
|
||||
|
||||
export function LocationPicker({
|
||||
lat,
|
||||
lng,
|
||||
onChange,
|
||||
height = 300,
|
||||
readOnly = false,
|
||||
showUserLocation = false,
|
||||
}: LocationPickerProps) {
|
||||
const geoCalled = useRef(false)
|
||||
const watchId = useRef<number | null>(null)
|
||||
const [userLoc, setUserLoc] = useState<UserLocation | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || !onChange) return
|
||||
if (!geoCalled.current && lat === 0 && lng === 0) {
|
||||
geoCalled.current = true
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) => onChange(pos.coords.latitude, pos.coords.longitude),
|
||||
() => {},
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Live user location tracking — only when requested
|
||||
useEffect(() => {
|
||||
if (!showUserLocation || !navigator.geolocation) return
|
||||
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
setUserLoc({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
})
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: true }
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (watchId.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchId.current)
|
||||
}
|
||||
}
|
||||
}, [showUserLocation])
|
||||
|
||||
const hasPin = lat !== 0 || lng !== 0
|
||||
const initialCenter: [number, number] = hasPin ? [lat, lng] : SRI_LANKA
|
||||
const initialZoom = hasPin ? 15 : 8
|
||||
|
||||
return (
|
||||
<div className="isolate overflow-hidden rounded-2xl border" style={{ height }}>
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={initialZoom}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_MAPTILER_KEY ? (
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url={`https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY}`}
|
||||
tileSize={512}
|
||||
zoomOffset={-1}
|
||||
maxZoom={22}
|
||||
/>
|
||||
) : (
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
maxZoom={19}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && onChange && <ClickHandler onChange={onChange} />}
|
||||
<AutoCenter lat={lat} lng={lng} />
|
||||
|
||||
{/* Store location pin */}
|
||||
{hasPin && (
|
||||
<Marker
|
||||
position={[lat, lng]}
|
||||
icon={pinIcon}
|
||||
draggable={!readOnly}
|
||||
eventHandlers={
|
||||
!readOnly && onChange
|
||||
? {
|
||||
dragend(e) {
|
||||
const pos = (e.target as L.Marker).getLatLng()
|
||||
onChange(pos.lat, pos.lng)
|
||||
},
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User's live location — accuracy halo + solid dot */}
|
||||
{showUserLocation && userLoc && (
|
||||
<>
|
||||
<Circle
|
||||
center={[userLoc.lat, userLoc.lng]}
|
||||
radius={userLoc.accuracy}
|
||||
pathOptions={{
|
||||
color: '#2563eb',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.15,
|
||||
weight: 1,
|
||||
}}
|
||||
/>
|
||||
<CircleMarker
|
||||
center={[userLoc.lat, userLoc.lng]}
|
||||
radius={7}
|
||||
pathOptions={{
|
||||
color: '#ffffff',
|
||||
fillColor: '#2563eb',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -24,15 +23,9 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index'
|
||||
import type { StoreResponse, RouteResponse } from '@/types/api'
|
||||
|
||||
// Leaflet uses browser APIs — must be loaded client-side only
|
||||
const LocationPicker = dynamic(
|
||||
() => import('./location-picker').then((m) => m.LocationPicker),
|
||||
{ ssr: false, loading: () => <div className="h-[300px] animate-pulse rounded-2xl bg-muted" /> }
|
||||
)
|
||||
|
||||
const schema = z.object({
|
||||
storeName: z.string().min(1, 'Store name is required'),
|
||||
ownerName: z.string().min(1, 'Owner name is required'),
|
||||
@@ -50,11 +43,16 @@ type FormValues = z.infer<typeof schema>
|
||||
interface StoreSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
store: StoreResponse
|
||||
store: StoreResponse | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
|
||||
return Array.isArray(value?.items) ? value.items : []
|
||||
}
|
||||
|
||||
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
|
||||
const isEdit = !!store
|
||||
const [routes, setRoutes] = useState<RouteResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
@@ -72,36 +70,53 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
},
|
||||
})
|
||||
|
||||
const lat = form.watch('latitude')
|
||||
const lng = form.watch('longitude')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
|
||||
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) =>
|
||||
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
|
||||
form.reset({
|
||||
storeName: store.storeName,
|
||||
ownerName: store.ownerName,
|
||||
storeAddress: store.storeAddress,
|
||||
routeId: store.routeId,
|
||||
latitude: store.latitude,
|
||||
longitude: store.longitude,
|
||||
creditLimit: store.creditLimit,
|
||||
segment: store.segment ?? '',
|
||||
isActive: store.isActive,
|
||||
})
|
||||
form.reset(
|
||||
store
|
||||
? {
|
||||
storeName: store.storeName,
|
||||
ownerName: store.ownerName,
|
||||
storeAddress: store.storeAddress,
|
||||
routeId: store.routeId,
|
||||
latitude: store.latitude,
|
||||
longitude: store.longitude,
|
||||
creditLimit: store.creditLimit,
|
||||
segment: store.segment ?? '',
|
||||
isActive: store.isActive,
|
||||
}
|
||||
: {
|
||||
storeName: '',
|
||||
ownerName: '',
|
||||
storeAddress: '',
|
||||
routeId: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
creditLimit: 0,
|
||||
segment: '',
|
||||
isActive: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [open, store])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await storesApi.update(store.id, {
|
||||
const body = {
|
||||
...values,
|
||||
segment: values.segment || undefined,
|
||||
})
|
||||
toast.add({ title: 'Store updated', type: 'success' })
|
||||
}
|
||||
if (isEdit) {
|
||||
await storesApi.update(store!.id, body)
|
||||
toast.add({ title: 'Store updated', type: 'success' })
|
||||
} else {
|
||||
await storesApi.create(body)
|
||||
toast.add({ title: 'Store created', type: 'success' })
|
||||
}
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
@@ -111,10 +126,12 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-xl">
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Store</DialogTitle>
|
||||
<DialogDescription>Update the store details below.</DialogDescription>
|
||||
<DialogTitle>{isEdit ? 'Edit Store' : 'Add Store'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
@@ -197,32 +214,43 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Location picker — replaces manual lat/lng inputs */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Store Location <span className="text-destructive">*</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click the map or drag the pin to update the location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LocationPicker
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
onChange={(newLat, newLng) => {
|
||||
form.setValue('latitude', newLat, { shouldValidate: true })
|
||||
form.setValue('longitude', newLng, { shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
|
||||
{(lat !== 0 || lng !== 0) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
label="Latitude"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="latitude"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
value={String(field.value as number)}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. 6.9271"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
label="Longitude"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="longitude"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
value={String(field.value as number)}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. 79.8612"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -284,7 +312,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Store'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stores as storesApi } from '@/lib/api/index'
|
||||
import type { StoreResponse, StoreVisitHistoryItem } from '@/types/api'
|
||||
|
||||
interface StoreVisitHistorySheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
store: StoreResponse | null
|
||||
}
|
||||
|
||||
export function StoreVisitHistorySheet({ open, onClose, store }: StoreVisitHistorySheetProps) {
|
||||
const [visits, setVisits] = useState<StoreVisitHistoryItem[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !store) return
|
||||
setVisits(null)
|
||||
storesApi.visitHistory(store.id)
|
||||
.then(setVisits)
|
||||
.catch(() => toast.add({ title: 'Failed to load visit history', type: 'error' }))
|
||||
}, [open, store])
|
||||
|
||||
if (!store) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Visit History — {store.storeName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Check-ins and check-outs logged against this store.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{visits === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : visits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No visits logged yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{visits.map((v) => (
|
||||
<li key={v.visitId} className="flex items-center justify-between rounded-md border p-2 text-sm">
|
||||
<span>
|
||||
{new Date(v.visitDate).toLocaleString('en-GB', {
|
||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<Badge variant={v.outcome === 'Completed' ? 'default' : 'secondary'}>
|
||||
{v.outcome}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
@@ -14,7 +13,6 @@ import { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/i
|
||||
import type { StoreResponse, KycStatus } from '@/types/api'
|
||||
|
||||
import { StoreSheet } from './store-sheet'
|
||||
import { StoreVisitHistorySheet } from './store-visit-history-sheet'
|
||||
|
||||
function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
|
||||
if (status === 'Verified') return 'default'
|
||||
@@ -23,7 +21,6 @@ function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destruct
|
||||
}
|
||||
|
||||
export function StoresClient() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<StoreResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -32,8 +29,6 @@ export function StoresClient() {
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<StoreResponse | null>(null)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [viewingHistory, setViewingHistory] = useState<StoreResponse | null>(null)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -62,11 +57,6 @@ export function StoresClient() {
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openHistory(store: StoreResponse) {
|
||||
setViewingHistory(store)
|
||||
setHistoryOpen(true)
|
||||
}
|
||||
|
||||
async function toggleActive(store: StoreResponse) {
|
||||
try {
|
||||
await storesApi.update(store.id, { isActive: !store.isActive })
|
||||
@@ -130,20 +120,10 @@ export function StoresClient() {
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View store',
|
||||
onClick: (store) => router.push(`/stores/${store.id}`),
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit store',
|
||||
onClick: (store) => router.push(`/stores/${store.id}/edit`),
|
||||
},
|
||||
{
|
||||
action: 'confirm',
|
||||
label: 'View visit history',
|
||||
onClick: openHistory,
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
action: 'deactivate',
|
||||
@@ -159,7 +139,7 @@ export function StoresClient() {
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize, openEdit, openHistory, toggleActive])
|
||||
}), [pageSize, openEdit, toggleActive])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -170,7 +150,7 @@ export function StoresClient() {
|
||||
Retail store accounts, credit limits, and KYC status
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/stores/new')}>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Store
|
||||
</Button>
|
||||
@@ -193,12 +173,6 @@ export function StoresClient() {
|
||||
store={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
|
||||
<StoreVisitHistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
store={viewingHistory}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RouteResponse } from '@/types/api'
|
||||
|
||||
const LocationPicker = dynamic(
|
||||
() => import('../_components/location-picker').then((m) => m.LocationPicker),
|
||||
{ ssr: false, loading: () => <div className="h-full min-h-[360px] animate-pulse rounded-2xl bg-muted" /> }
|
||||
)
|
||||
|
||||
const schema = z.object({
|
||||
storeName: z.string().min(1, 'Store name is required'),
|
||||
ownerName: z.string().min(1, 'Owner name is required'),
|
||||
storeAddress: z.string().min(1, 'Store address is required'),
|
||||
routeId: z.string().min(1, 'Route is required'),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
creditLimit: z.number().min(0),
|
||||
segment: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export default function NewStorePage() {
|
||||
const router = useRouter()
|
||||
const [routes, setRoutes] = useState<RouteResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
storeName: '',
|
||||
ownerName: '',
|
||||
storeAddress: '',
|
||||
routeId: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
creditLimit: 0,
|
||||
segment: '',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
const lat = form.watch('latitude')
|
||||
const lng = form.watch('longitude')
|
||||
|
||||
useEffect(() => {
|
||||
routesApi.list({ pageSize: 100 })
|
||||
.then((res) => setRoutes(getPaginatedItems(res)))
|
||||
.catch((err) =>
|
||||
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
}, [])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await storesApi.create({
|
||||
...values,
|
||||
segment: values.segment || undefined,
|
||||
})
|
||||
toast.add({ title: 'Store created', type: 'success' })
|
||||
router.push('/stores')
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to create store', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/stores')}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Add Store</h1>
|
||||
<p className="text-sm text-muted-foreground">Add a new retail store to the network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Left — text fields */}
|
||||
<div className="lg:col-span-1">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeName"
|
||||
label="Store Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="storeName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Sunrise Mart"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ownerName"
|
||||
label="Owner Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="ownerName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Kamal Perera"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeAddress"
|
||||
label="Store Address"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="storeAddress"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. 123 Main St, Colombo"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routeId"
|
||||
label="Route"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a route" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routes.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.routeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="creditLimit"
|
||||
label="Credit Limit"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="creditLimit"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
value={String(field.value as number)}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="segment"
|
||||
label="Segment"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="segment"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Retail"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push('/stores')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Creating…' : 'Create Store'}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
{/* Right — map */}
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Store Location <span className="text-destructive">*</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your current location is used as the starting point. Click the map or drag the pin to adjust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<LocationPicker
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
height={520}
|
||||
showUserLocation
|
||||
onChange={(newLat, newLng) => {
|
||||
form.setValue('latitude', newLat, { shouldValidate: true })
|
||||
form.setValue('longitude', newLng, { shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(lat !== 0 || lng !== 0) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { GridLayout, useContainerWidth, type LayoutItem } from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { dashboard, type WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
|
||||
import { ROLE_DEFAULT_LAYOUTS } from '@/lib/widgets/defaults'
|
||||
import { getApiError } from '@/lib/api'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { WidgetContainer } from './widget-container'
|
||||
import { WidgetPicker } from './widget-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
PencilEdit01Icon,
|
||||
Tick02Icon,
|
||||
Cancel01Icon,
|
||||
Add01Icon,
|
||||
Refresh01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
|
||||
// ─── Layout helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function toGridItems(items: WidgetLayoutItem[], editable: boolean): LayoutItem[] {
|
||||
return items.map(item => {
|
||||
const def = WIDGET_REGISTRY[item.widgetType]
|
||||
return {
|
||||
i: item.i,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
minW: def?.minW ?? 2,
|
||||
minH: def?.minH ?? 2,
|
||||
...(def?.maxW ? { maxW: def.maxW } : {}),
|
||||
isDraggable: editable,
|
||||
isResizable: editable,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyGridChanges(updated: readonly LayoutItem[], current: WidgetLayoutItem[]): WidgetLayoutItem[] {
|
||||
const typeMap = Object.fromEntries(current.map(item => [item.i, item.widgetType]))
|
||||
return updated
|
||||
.filter(l => typeMap[l.i])
|
||||
.map(l => ({ i: l.i, widgetType: typeMap[l.i], x: l.x, y: l.y, w: l.w, h: l.h }))
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DashboardCanvas() {
|
||||
const { user } = useAuth()
|
||||
const { width, containerRef } = useContainerWidth()
|
||||
|
||||
const [savedLayout, setSavedLayout] = useState<WidgetLayoutItem[]>([])
|
||||
const [editingLayout, setEditingLayout] = useState<WidgetLayoutItem[]>([])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
const roleDefault = ROLE_DEFAULT_LAYOUTS[user.role] ?? []
|
||||
|
||||
// ─── Load ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const loadPreferences = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await dashboard.getPreferences()
|
||||
setSavedLayout(res.layout)
|
||||
} catch {
|
||||
setSavedLayout(roleDefault)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user.role])
|
||||
|
||||
useEffect(() => { loadPreferences() }, [loadPreferences])
|
||||
|
||||
// ─── Edit mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
function startEditing() {
|
||||
setEditingLayout([...savedLayout])
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingLayout([])
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
async function saveLayout() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await dashboard.savePreferences(editingLayout)
|
||||
setSavedLayout(editingLayout)
|
||||
setIsEditing(false)
|
||||
toast.add({ title: 'Dashboard saved', type: 'success' })
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setEditingLayout([...roleDefault])
|
||||
}
|
||||
|
||||
// ─── Grid mutations ────────────────────────────────────────────────────────
|
||||
|
||||
function onGridChange(updated: readonly LayoutItem[]) {
|
||||
if (!isEditing) return
|
||||
setEditingLayout(prev => applyGridChanges(updated, prev))
|
||||
}
|
||||
|
||||
function removeWidget(id: string) {
|
||||
setEditingLayout(prev => prev.filter(item => item.i !== id))
|
||||
}
|
||||
|
||||
function addWidget(widgetType: string) {
|
||||
const def = WIDGET_REGISTRY[widgetType]
|
||||
if (!def) return
|
||||
const newItem: WidgetLayoutItem = {
|
||||
i: `${widgetType}-${Date.now()}`,
|
||||
widgetType,
|
||||
x: 0,
|
||||
y: Infinity,
|
||||
w: def.defaultW,
|
||||
h: def.defaultH,
|
||||
}
|
||||
setEditingLayout(prev => [...prev, newItem])
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const activeLayout = isEditing ? editingLayout : savedLayout
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Drag to reposition · Resize from the corner · Add or remove widgets'
|
||||
: `Welcome back, ${user.fullName}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={resetToDefaults} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={14} strokeWidth={2} />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||
Add widget
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={cancelEditing} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={saveLayout} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Tick02Icon} size={14} strokeWidth={2} />
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={startEditing}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
|
||||
Customize
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{activeLayout.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed py-20 text-center">
|
||||
<p className="text-sm font-medium text-foreground">Your dashboard is empty</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Click Customize, then Add widget to get started.
|
||||
</p>
|
||||
{!isEditing && (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={startEditing}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
|
||||
Customize
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
<GridLayout
|
||||
width={width}
|
||||
layout={toGridItems(activeLayout, isEditing)}
|
||||
gridConfig={{ cols: 12, rowHeight: 80, margin: [12, 12] as const }}
|
||||
dragConfig={{ handle: '.drag-handle' }}
|
||||
resizeConfig={{ handles: ['se'] as const }}
|
||||
onLayoutChange={onGridChange}
|
||||
className="relative"
|
||||
>
|
||||
{activeLayout.map(item => (
|
||||
<div key={item.i}>
|
||||
<WidgetContainer
|
||||
widgetType={item.widgetType}
|
||||
editMode={isEditing}
|
||||
onRemove={() => removeWidget(item.i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Widget Picker */}
|
||||
<WidgetPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
role={user.role}
|
||||
currentLayout={editingLayout}
|
||||
onAdd={addWidget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Cancel01Icon, DragDropVerticalIcon } from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WidgetContainerProps {
|
||||
widgetType: string
|
||||
editMode: boolean
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function WidgetContainer({ widgetType, editMode, onRemove }: WidgetContainerProps) {
|
||||
const def = WIDGET_REGISTRY[widgetType]
|
||||
const Component = def?.component
|
||||
|
||||
return (
|
||||
<Card className={cn('flex h-full flex-col overflow-hidden transition-shadow', editMode && 'ring-1 ring-border')}>
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 pb-2 pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{editMode && (
|
||||
<span
|
||||
className="drag-handle shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<HugeiconsIcon icon={DragDropVerticalIcon} size={14} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<CardTitle className="truncate">{def?.label ?? widgetType}</CardTitle>
|
||||
</div>
|
||||
{editMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${def?.label ?? widgetType} widget`}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden pt-0">
|
||||
{Component ? (
|
||||
<Component />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Unknown widget type: {widgetType}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { getWidgetsForRole, type WidgetDefinition } from '@/lib/widgets/registry'
|
||||
import type { Role } from '@/contexts/auth-context'
|
||||
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
interface WidgetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
role: Role
|
||||
currentLayout: WidgetLayoutItem[]
|
||||
onAdd: (widgetType: string) => void
|
||||
}
|
||||
|
||||
export function WidgetPicker({ open, onClose, role, currentLayout, onAdd }: WidgetPickerProps) {
|
||||
const available = getWidgetsForRole(role)
|
||||
const activeTypes = new Set(currentLayout.map(l => l.widgetType))
|
||||
|
||||
function handleAdd(def: WidgetDefinition) {
|
||||
onAdd(def.type)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-sm">
|
||||
<SheetHeader className="px-4 pb-3 pt-4">
|
||||
<SheetTitle>Add Widget</SheetTitle>
|
||||
<SheetDescription>
|
||||
Choose a widget to add to your dashboard.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-auto p-4">
|
||||
{available.map(def => {
|
||||
const alreadyAdded = activeTypes.has(def.type)
|
||||
return (
|
||||
<div
|
||||
key={def.type}
|
||||
className="flex items-start justify-between gap-3 rounded-xl border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{def.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Default size: {def.defaultW}×{def.defaultH}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={alreadyAdded ? 'outline' : 'default'}
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={() => handleAdd(def)}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||
{alreadyAdded ? 'Add another' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { rides } from '@/lib/api/index'
|
||||
import type { RideResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export function ActiveRidesWidget() {
|
||||
const [data, setData] = useState<RideResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await rides.list({ status: 'Active', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent — widget shows empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">active ride{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active rides right now</p>
|
||||
) : (
|
||||
data.map(ride => (
|
||||
<div
|
||||
key={ride.id}
|
||||
className="flex items-center justify-between rounded-xl border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{ride.driverFullName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{ride.routeName} · {ride.vehicleNumberPlate}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{ride.rideType}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { loadingSheets } from '@/lib/api/index'
|
||||
import type { LoadingSheetResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Pending: 'outline',
|
||||
DispatchConfirmed: 'secondary',
|
||||
ReceiptConfirmed: 'default',
|
||||
DiscrepancyFlagged: 'destructive',
|
||||
Resolved: 'default',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
Pending: 'Pending',
|
||||
DispatchConfirmed: 'Dispatched',
|
||||
ReceiptConfirmed: 'Received',
|
||||
DiscrepancyFlagged: 'Discrepancy',
|
||||
Resolved: 'Resolved',
|
||||
}
|
||||
|
||||
export function LoadingSheetsWidget() {
|
||||
const [data, setData] = useState<LoadingSheetResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await loadingSheets.list({ status: 'Pending', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">pending sheet{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending loading sheets</p>
|
||||
) : (
|
||||
data.map(sheet => (
|
||||
<div key={sheet.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{sheet.warehouseName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sheet.items.length} item{sheet.items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={STATUS_VARIANT[sheet.status] ?? 'outline'} className="ml-2 shrink-0">
|
||||
{STATUS_LABEL[sheet.status] ?? sheet.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { purchaseOrders } from '@/lib/api/index'
|
||||
import type { PurchaseOrderResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function PendingPOsWidget() {
|
||||
const [data, setData] = useState<PurchaseOrderResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await purchaseOrders.list({ status: 'Issued', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">pending order{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending purchase orders</p>
|
||||
) : (
|
||||
data.map(po => (
|
||||
<div key={po.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{po.supplierName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{po.items.length} line{po.items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-2 shrink-0 text-sm font-semibold">
|
||||
Rs. {po.items.reduce((s, i) => s + i.quantity * i.unitCost, 0).toLocaleString('en-LK', { minimumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { orders } from '@/lib/api/index'
|
||||
import type { OrderResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Pending: 'outline',
|
||||
PartiallyFulfilled: 'secondary',
|
||||
Fulfilled: 'default',
|
||||
Cancelled: 'destructive',
|
||||
}
|
||||
|
||||
export function RecentOrdersWidget() {
|
||||
const [data, setData] = useState<OrderResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await orders.list({ pageSize: 6, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No orders yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.map(order => (
|
||||
<div key={order.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
#{order.id.slice(0, 8).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
Rs. {order.totalAmount.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={STATUS_VARIANT[order.status] ?? 'outline'} className="ml-2 shrink-0">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { stock } from '@/lib/api/index'
|
||||
import type { StockBalanceResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 10
|
||||
|
||||
export function StockAlertsWidget() {
|
||||
const [alerts, setAlerts] = useState<StockBalanceResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await stock.balances({ pageSize: 100, page: 1 })
|
||||
const low = (res.items ?? []).filter(b => b.quantity <= LOW_STOCK_THRESHOLD)
|
||||
setAlerts(low)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const outOfStock = alerts.filter(a => a.quantity === 0)
|
||||
const lowStock = alerts.filter(a => a.quantity > 0)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{alerts.length}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
alert{alerts.length !== 1 ? 's' : ''}
|
||||
{outOfStock.length > 0 && (
|
||||
<span className="ml-1 text-destructive">({outOfStock.length} out of stock)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">All stock levels are healthy</p>
|
||||
) : (
|
||||
alerts.map(b => (
|
||||
<div key={`${b.itemId}-${b.locationId}`} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{b.itemName}</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">{b.locationType}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={b.quantity === 0 ? 'destructive' : 'outline'}
|
||||
className="ml-2 shrink-0"
|
||||
>
|
||||
{b.quantity === 0 ? 'Out of stock' : `${b.quantity} left`}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { sales } from '@/lib/api/index'
|
||||
import type { SaleResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function TodaySalesWidget() {
|
||||
const [items, setItems] = useState<SaleResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
try {
|
||||
const res = await sales.list({ from: today, to: today, pageSize: 50, page: 1 })
|
||||
setItems(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const revenue = items.reduce((sum, s) => sum + s.totalAmount, 0)
|
||||
const completed = items.filter(s => s.status === 'Completed').length
|
||||
const pending = items.filter(s => s.status === 'Pending').length
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold">
|
||||
Rs. {revenue.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">revenue today</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold">{total}</span>
|
||||
<span className="text-xs text-muted-foreground">total</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold text-green-600">{completed}</span>
|
||||
<span className="text-xs text-muted-foreground">completed</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold text-amber-500">{pending}</span>
|
||||
<span className="text-xs text-muted-foreground">pending</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type ExportFormat = 'csv' | 'excel' | 'pdf';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
export type RowActionKind =
|
||||
| 'view'
|
||||
| 'edit'
|
||||
| 'activate'
|
||||
| 'deactivate'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Delete02Icon,
|
||||
EyeIcon,
|
||||
PencilEdit01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
@@ -20,7 +19,6 @@ const ACTION_ICONS = {
|
||||
confirm: CheckmarkCircle02Icon,
|
||||
deactivate: Cancel01Icon,
|
||||
edit: PencilEdit01Icon,
|
||||
view: EyeIcon,
|
||||
} as const
|
||||
|
||||
type RowActionKind = keyof typeof ACTION_ICONS
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn('rounded-2xl border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn('flex flex-col gap-1 px-4 pt-4 pb-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-title"
|
||||
className={cn('text-sm font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn('text-xs text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
@@ -73,7 +73,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Store01Icon,
|
||||
items: [
|
||||
{ title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' },
|
||||
{ title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -138,7 +137,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Building02Icon,
|
||||
items: [
|
||||
{ title: 'Regions', href: '/network/regions', icon: Location01Icon, description: 'Geographic territories the distribution network operates in', permission: 'regions.view' },
|
||||
{ title: 'Region Manage', href: '/network/region-manage', icon: ClipboardListIcon, description: 'Targets, budgets, tax rules, and agency mapping per region', permission: 'regions.view' },
|
||||
{ title: 'Routes', href: '/network/routes', icon: Route01Icon, description: 'Delivery paths within a region that rides are assigned to', permission: 'routes.view' },
|
||||
{ title: 'Vehicles', href: '/network/vehicles', icon: Car01Icon, description: 'Fleet vehicles available for delivery rides', permission: 'vehicles.view' },
|
||||
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
|
||||
|
||||
@@ -51,7 +51,6 @@ const MOCK_PERMISSIONS: Record<Role, string[]> = {
|
||||
'routes.view',
|
||||
'rides.view',
|
||||
'driver_profiles.view',
|
||||
'sales_visits.checkin',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export interface WidgetLayoutItem {
|
||||
i: string
|
||||
widgetType: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface DashboardPreferencesResponse {
|
||||
userId: string | null
|
||||
role: string
|
||||
isDefault: boolean
|
||||
layout: WidgetLayoutItem[]
|
||||
}
|
||||
|
||||
export const dashboard = {
|
||||
getPreferences: () =>
|
||||
api.get<DashboardPreferencesResponse>('/dashboard/preferences').then(r => r.data),
|
||||
|
||||
savePreferences: (layout: WidgetLayoutItem[]) =>
|
||||
api.put<DashboardPreferencesResponse>('/dashboard/preferences', { layout }).then(r => r.data),
|
||||
|
||||
getDefaults: (role: string) =>
|
||||
api.get<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`).then(r => r.data),
|
||||
|
||||
saveDefaults: (role: string, layout: WidgetLayoutItem[]) =>
|
||||
api.put<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`, { layout }).then(r => r.data),
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { sales, orders, payments } from './sales'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
export { dashboard } from './dashboard'
|
||||
export type { WidgetLayoutItem, DashboardPreferencesResponse } from './dashboard'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
export { getApiError } from '@/lib/api'
|
||||
|
||||
@@ -2,13 +2,7 @@ import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
RegionResponse,
|
||||
ManageRegionResponse,
|
||||
RegionHierarchyResponse,
|
||||
RegionDashboardResponse,
|
||||
RegionManageEntryResponse,
|
||||
RegionManageEntryType,
|
||||
RouteResponse,
|
||||
RouteVisitResponse,
|
||||
VehicleResponse,
|
||||
WarehouseResponse,
|
||||
VehicleType,
|
||||
@@ -102,25 +96,6 @@ export const regions = {
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/regions/${id}`),
|
||||
|
||||
// Consolidated replacement for the old separate setTarget/setBudget/mapAgency/setTaxRule
|
||||
// calls — pass only the fields you want to change (Period is required alongside targetValue
|
||||
// and/or budgetAmount).
|
||||
manage: (id: string, body: { period?: string; targetValue?: number; budgetAmount?: number; agencyUserId?: string; ruleConfig?: string }) =>
|
||||
api.put<ManageRegionResponse>(`/regions/${id}/manage`, body).then(r => r.data),
|
||||
|
||||
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
|
||||
manageEntries: () =>
|
||||
api.get<RegionManageEntryResponse[]>('/regions/manage-entries').then(r => r.data),
|
||||
|
||||
removeManageEntry: (entryType: RegionManageEntryType, id: string) =>
|
||||
api.delete(`/regions/manage-entries/${entryType}/${id}`),
|
||||
|
||||
hierarchy: (id: string) =>
|
||||
api.get<RegionHierarchyResponse>(`/regions/${id}/hierarchy`).then(r => r.data),
|
||||
|
||||
dashboard: (id: string, period?: string) =>
|
||||
api.get<RegionDashboardResponse>(`/regions/${id}/dashboard`, { params: { period } }).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
@@ -149,18 +124,6 @@ export const routes = {
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/routes/${id}`),
|
||||
|
||||
assignStores: (id: string, storeIds: string[]) =>
|
||||
api.post<RouteResponse>(`/routes/${id}/stores`, { storeIds }).then(r => r.data),
|
||||
|
||||
assignOwner: (id: string, salesRepId: string) =>
|
||||
api.put<RouteResponse>(`/routes/${id}/owner`, { salesRepId }).then(r => r.data),
|
||||
|
||||
checkIn: (id: string, body: { storeId: string; latitude: number; longitude: number }) =>
|
||||
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-in`, body).then(r => r.data),
|
||||
|
||||
checkOut: (id: string, body: { storeId: string }) =>
|
||||
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-out`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
KycDocumentResponse,
|
||||
OutstandingBalanceResponse,
|
||||
StoreDashboardResponse,
|
||||
StoreVisitHistoryItem,
|
||||
KycStatus,
|
||||
} from '@/types/api'
|
||||
|
||||
@@ -59,7 +58,4 @@ export const stores = {
|
||||
|
||||
updateKycStatus: (id: string, kycStatus: KycStatus) =>
|
||||
api.patch<StoreResponse>(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data),
|
||||
|
||||
visitHistory: (id: string) =>
|
||||
api.get<StoreVisitHistoryItem[]>(`/stores/${id}/visit-history`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
|
||||
export const ROLE_DEFAULT_LAYOUTS: Record<string, WidgetLayoutItem[]> = {
|
||||
admin: [
|
||||
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
|
||||
{ i: 'active-rides-1', widgetType: 'active-rides', x: 4, y: 0, w: 4, h: 4 },
|
||||
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 8, y: 0, w: 4, h: 4 },
|
||||
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 0, y: 4, w: 4, h: 4 },
|
||||
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 4, y: 4, w: 4, h: 4 },
|
||||
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 0, y: 8, w: 8, h: 4 },
|
||||
],
|
||||
warehouse: [
|
||||
{ i: 'active-rides-1', widgetType: 'active-rides', x: 0, y: 0, w: 6, h: 4 },
|
||||
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 6, y: 0, w: 6, h: 4 },
|
||||
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 0, y: 4, w: 6, h: 4 },
|
||||
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 6, y: 4, w: 6, h: 4 },
|
||||
],
|
||||
sales: [
|
||||
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
|
||||
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 4, y: 0, w: 8, h: 4 },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Role } from '@/contexts/auth-context'
|
||||
|
||||
import { ActiveRidesWidget } from '@/components/dashboard/widgets/active-rides-widget'
|
||||
import { TodaySalesWidget } from '@/components/dashboard/widgets/today-sales-widget'
|
||||
import { PendingPOsWidget } from '@/components/dashboard/widgets/pending-pos-widget'
|
||||
import { StockAlertsWidget } from '@/components/dashboard/widgets/stock-alerts-widget'
|
||||
import { RecentOrdersWidget } from '@/components/dashboard/widgets/recent-orders-widget'
|
||||
import { LoadingSheetsWidget } from '@/components/dashboard/widgets/loading-sheets-widget'
|
||||
|
||||
export interface WidgetDefinition {
|
||||
type: string
|
||||
label: string
|
||||
description: string
|
||||
defaultW: number
|
||||
defaultH: number
|
||||
minW: number
|
||||
minH: number
|
||||
maxW?: number
|
||||
allowedRoles: Role[] | '*'
|
||||
component: ComponentType
|
||||
}
|
||||
|
||||
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
||||
'active-rides': {
|
||||
type: 'active-rides',
|
||||
label: 'Active Rides',
|
||||
description: 'Live view of all currently active rides with driver and route info.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: ActiveRidesWidget,
|
||||
},
|
||||
'today-sales': {
|
||||
type: 'today-sales',
|
||||
label: "Today's Sales",
|
||||
description: "Total revenue and sales counts for today.",
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 2,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'sales'],
|
||||
component: TodaySalesWidget,
|
||||
},
|
||||
'pending-purchase-orders': {
|
||||
type: 'pending-purchase-orders',
|
||||
label: 'Pending Purchase Orders',
|
||||
description: 'Purchase orders that have been issued but not yet fully received.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: PendingPOsWidget,
|
||||
},
|
||||
'stock-alerts': {
|
||||
type: 'stock-alerts',
|
||||
label: 'Stock Alerts',
|
||||
description: 'Items at zero or critically low stock levels across all locations.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: StockAlertsWidget,
|
||||
},
|
||||
'recent-orders': {
|
||||
type: 'recent-orders',
|
||||
label: 'Recent Orders',
|
||||
description: 'Latest customer orders with status and value.',
|
||||
defaultW: 5,
|
||||
defaultH: 4,
|
||||
minW: 4,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'sales'],
|
||||
component: RecentOrdersWidget,
|
||||
},
|
||||
'loading-sheets': {
|
||||
type: 'loading-sheets',
|
||||
label: 'Pending Loading Sheets',
|
||||
description: 'Loading sheets awaiting dispatch confirmation.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: LoadingSheetsWidget,
|
||||
},
|
||||
}
|
||||
|
||||
export function getWidgetsForRole(role: Role): WidgetDefinition[] {
|
||||
return Object.values(WIDGET_REGISTRY).filter(def =>
|
||||
def.allowedRoles === '*' || def.allowedRoles.includes(role)
|
||||
)
|
||||
}
|
||||
Generated
+54
-47
@@ -12,17 +12,15 @@
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@hugeicons/core-free-icons": "^4.3.0",
|
||||
"@hugeicons/react": "^1.1.10",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"axios": "^1.19.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -1983,17 +1981,6 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2398,12 +2385,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2418,15 +2399,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -5121,6 +5093,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
|
||||
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -6650,12 +6628,6 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -7004,7 +6976,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -8122,7 +8093,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -8247,6 +8217,38 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
|
||||
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-grid-layout": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
|
||||
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"fast-equals": "^4.0.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-resizable": "^3.1.3",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.86.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
|
||||
@@ -8267,21 +8269,20 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
|
||||
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
"prop-types": "15.x",
|
||||
"react-draggable": "^4.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react": ">= 16.3",
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
@@ -8359,6 +8360,12 @@
|
||||
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
|
||||
@@ -16,17 +16,15 @@
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@hugeicons/core-free-icons": "^4.3.0",
|
||||
"@hugeicons/react": "^1.1.10",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"axios": "^1.19.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -81,96 +81,10 @@ export interface RouteResponse {
|
||||
startPoint: string
|
||||
endPoint: string
|
||||
length: number
|
||||
ownerUserId: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface RouteVisitResponse {
|
||||
id: string
|
||||
routeId: string
|
||||
storeId: string
|
||||
userId: string
|
||||
checkInAt: string
|
||||
isDeviationFlagged: boolean
|
||||
checkOutAt: string | null
|
||||
}
|
||||
|
||||
export interface StoreVisitHistoryItem {
|
||||
visitId: string
|
||||
visitDate: string
|
||||
repUserId: string
|
||||
outcome: string
|
||||
}
|
||||
|
||||
export interface RegionalTargetResponse {
|
||||
regionId: string
|
||||
period: string
|
||||
targetValue: number
|
||||
}
|
||||
|
||||
export interface RegionalBudgetResponse {
|
||||
regionId: string
|
||||
period: string
|
||||
budgetAmount: number
|
||||
}
|
||||
|
||||
export interface RegionAgencyMapResponse {
|
||||
regionId: string
|
||||
agencyUserId: string
|
||||
}
|
||||
|
||||
export interface RegionalTaxRuleResponse {
|
||||
regionId: string
|
||||
ruleConfig: string
|
||||
}
|
||||
|
||||
export interface ManageRegionResponse {
|
||||
regionId: string
|
||||
target: RegionalTargetResponse | null
|
||||
budget: RegionalBudgetResponse | null
|
||||
agencyMap: RegionAgencyMapResponse | null
|
||||
taxRule: RegionalTaxRuleResponse | null
|
||||
}
|
||||
|
||||
export interface RegionHierarchyRouteItem {
|
||||
routeId: string
|
||||
routeName: string
|
||||
storeIds: string[]
|
||||
}
|
||||
|
||||
export interface RegionHierarchyResponse {
|
||||
regionId: string
|
||||
regionName: string
|
||||
routes: RegionHierarchyRouteItem[]
|
||||
}
|
||||
|
||||
export interface RegionDashboardResponse {
|
||||
regionId: string
|
||||
period: string
|
||||
targetValue: number | null
|
||||
budgetAmount: number | null
|
||||
orderCount: number
|
||||
actualSales: number
|
||||
lastOrderDate: string | null
|
||||
taxRuleConfig: string | null
|
||||
agencyUserIds: string[]
|
||||
}
|
||||
|
||||
export type RegionManageEntryType = 'Target' | 'Budget' | 'TaxRule' | 'AgencyMap'
|
||||
|
||||
export interface RegionManageEntryResponse {
|
||||
id: string
|
||||
regionId: string
|
||||
regionName: string
|
||||
entryType: RegionManageEntryType
|
||||
period: string | null
|
||||
amount: number | null
|
||||
ruleConfig: string | null
|
||||
agencyUserId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ─── Vehicles / Warehouses ───────────────────────────────────────────────────
|
||||
|
||||
export interface VehicleResponse {
|
||||
|
||||
@@ -506,81 +506,6 @@ Array of:
|
||||
}
|
||||
```
|
||||
|
||||
**Beyond CRUD** — manage (targets/budgets/agency mapping/tax rules, consolidated), hierarchy, and
|
||||
dashboard. Not gated by the `regions.*` permission codes above.
|
||||
|
||||
> ⚠️ **Authorization temporarily disabled**: `ManageAsync` currently allows any authenticated
|
||||
> caller — the previous role-level gate (`RegionalManager`, level 50, scoped via
|
||||
> `UserRegionScope`; `RegionalAgency`, level 60, any region) was deliberately stubbed out
|
||||
> (`CanManageRegionAsync` always returns `true`) pending a decision on the role model for this
|
||||
> endpoint. Re-enabling it is tracked as a TODO in `RegionService.cs`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `PUT` | `/api/regions/{id}/manage` | Set target/budget/agency-map/tax-rule in one call — replaces the old separate targets/budgets/agencies/tax-rule endpoints |
|
||||
| `GET` | `/api/regions/{id}/hierarchy` | Region's routes and each route's store ids |
|
||||
| `GET` | `/api/regions/{id}/dashboard?period=yyyy-MM` | Real-time order count/sales/target/budget for the period |
|
||||
|
||||
**Manage request** — every field is optional; only the sub-actions whose fields are present get
|
||||
applied, so a caller can update just one thing or several in a single request. `period` is
|
||||
required alongside `targetValue` and/or `budgetAmount` (both are silently skipped without it):
|
||||
```json
|
||||
// PUT /api/regions/{id}/manage
|
||||
{
|
||||
"period": "2026-08", // required for targetValue/budgetAmount
|
||||
"targetValue": 500000, // optional
|
||||
"budgetAmount": 200000, // optional
|
||||
"agencyUserId": "guid", // optional — must be a User with the RegionalAgency role
|
||||
"ruleConfig": "{\"rate\": 0.15}" // optional — arbitrary JSON stored as a string, not yet consumed by pricing
|
||||
}
|
||||
```
|
||||
|
||||
**Manage response** — each field is `null` if that sub-action wasn't requested:
|
||||
```json
|
||||
{
|
||||
"regionId": "guid",
|
||||
"target": { "regionId": "guid", "period": "2026-08", "targetValue": 500000 } | null,
|
||||
"budget": { "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 } | null,
|
||||
"agencyMap": { "regionId": "guid", "agencyUserId": "guid" } | null,
|
||||
"taxRule": { "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" } | null
|
||||
}
|
||||
```
|
||||
|
||||
**Hierarchy response:**
|
||||
```json
|
||||
{
|
||||
"regionId": "guid",
|
||||
"regionName": "string",
|
||||
"routes": [
|
||||
{ "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Dashboard response:**
|
||||
```json
|
||||
{
|
||||
"regionId": "guid",
|
||||
"period": "2026-08",
|
||||
"targetValue": 500000, // null if no target set for the period
|
||||
"budgetAmount": 200000, // null if no budget set for the period
|
||||
"orderCount": 42,
|
||||
"actualSales": 187500.00,
|
||||
"lastOrderDate": "datetime | null",
|
||||
"taxRuleConfig": "{\"rate\": 0.15}" | null, // current tax rule, independent of period
|
||||
"agencyUserIds": ["guid", "..."] // all agency users currently mapped to this region
|
||||
}
|
||||
```
|
||||
`taxRuleConfig` and `agencyUserIds` are not period-scoped — they reflect current state regardless
|
||||
of the `period` query param, added so the frontend's region-manage dialog has something to render
|
||||
after calling `manage` (previously the dashboard only echoed back target/budget, leaving tax-rule
|
||||
and agency-mapping changes invisible in the UI after saving).
|
||||
|
||||
All three of these endpoints return `404` if the region doesn't exist, or (for `manage`, when
|
||||
`agencyUserId` is supplied) the target user doesn't hold the `RegionalAgency` role — the response
|
||||
body carries no distinction between "not found" and "invalid agency user". (The former
|
||||
"forbidden due to region scope" case doesn't currently apply — see the authorization note above.)
|
||||
|
||||
---
|
||||
|
||||
### 6.6 Routes
|
||||
@@ -593,7 +518,6 @@ body carries no distinction between "not found" and "invalid agency user". (The
|
||||
| `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID |
|
||||
| `POST` | `/api/routes` | `routes.create` | Create route |
|
||||
| `PUT` | `/api/routes/{id}` | `routes.update` | Update route |
|
||||
| `PUT` | `/api/routes/{id}/region` | `routes.update` | Assign/reassign the route to a region |
|
||||
| `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route |
|
||||
|
||||
**Create/Update request:**
|
||||
@@ -616,95 +540,11 @@ body carries no distinction between "not found" and "invalid agency user". (The
|
||||
"startPoint": "string",
|
||||
"endPoint": "string",
|
||||
"length": 12.5,
|
||||
"ownerUserId": "guid | null",
|
||||
"isActive": true,
|
||||
"createdAt": "datetime"
|
||||
}
|
||||
```
|
||||
|
||||
**Assign to region request:**
|
||||
```json
|
||||
// PUT /api/routes/{id}/region
|
||||
{ "regionId": "guid" }
|
||||
```
|
||||
Returns the same `RouteResponse` shape as above. `404` if the route or the target region doesn't
|
||||
exist (or either is inactive).
|
||||
|
||||
#### Route & Sales Visit Management
|
||||
|
||||
Bulk store assignment, owner assignment, and GPS check-in/out for a SalesRep's daily visit
|
||||
schedule. Not gated by the `routes.*` permission codes above — access is checked in
|
||||
`RouteService` via `IScopeAccessService.HasRouteScopeAsync`, with a `RouteManagerRoleLevel` (20,
|
||||
i.e. Supervisor and above) floor for the assignment endpoints. All endpoints below return `404`
|
||||
if the route doesn't exist, is inactive, or the caller lacks route scope — the response body
|
||||
carries no distinction between "not found" and "forbidden".
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/routes/{id}/stores` | Bulk-assign a set of stores to this route (replaces each store's `routeId`) |
|
||||
| `PUT` | `/api/routes/{id}/owner` | Assign or reassign the route's owning SalesRep (`OwnerUserId`) |
|
||||
| `POST` | `/api/routes/{id}/optimize` | Route sequence optimization — **not yet implemented**, always returns `501 Not Implemented` |
|
||||
| `POST` | `/api/routes/{id}/visits/check-in` | Record a GPS check-in at a store on this route |
|
||||
| `POST` | `/api/routes/{id}/visits/check-out` | Record check-out for an open visit at a store on this route |
|
||||
|
||||
**Assign stores request / response:**
|
||||
```json
|
||||
// POST /api/routes/{id}/stores
|
||||
{ "storeIds": ["guid", "guid", "..."] }
|
||||
```
|
||||
Returns the `RouteResponse` shape. All IDs must reference existing stores or the request is
|
||||
rejected with `404` (no partial assignment).
|
||||
```json
|
||||
{ "id": "guid", "regionId": "guid", "routeName": "string", "startPoint": "string", "endPoint": "string", "length": 12.5, "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" }
|
||||
```
|
||||
|
||||
**Assign owner request / response:**
|
||||
```json
|
||||
// PUT /api/routes/{id}/owner
|
||||
{ "salesRepId": "guid" } // must be an active User
|
||||
```
|
||||
Returns the same `RouteResponse` shape. `404` if the target user doesn't exist or is inactive.
|
||||
|
||||
**Check-in request / response:**
|
||||
```json
|
||||
// POST /api/routes/{id}/visits/check-in
|
||||
{
|
||||
"storeId": "guid", // must currently be assigned to this route
|
||||
"latitude": 6.927079,
|
||||
"longitude": 79.861244,
|
||||
"timestamp": "datetime | null" // optional, defaults to server time
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"id": "guid",
|
||||
"routeId": "guid",
|
||||
"storeId": "guid",
|
||||
"userId": "guid", // caller's user ID — the visiting rep
|
||||
"checkInAt": "datetime",
|
||||
"isDeviationFlagged": false, // true if GPS is > 500m (Haversine) from the store's registered lat/long
|
||||
"checkOutAt": "datetime | null"
|
||||
}
|
||||
```
|
||||
`isDeviationFlagged` is a basic distance check only — it does not yet trigger any alert/notification
|
||||
(see [docs/09_fraud_controls.md] `sendRouteDeviationAlert`, which is not implemented).
|
||||
|
||||
**Check-out request / response:**
|
||||
```json
|
||||
// POST /api/routes/{id}/visits/check-out
|
||||
{
|
||||
"storeId": "guid",
|
||||
"timestamp": "datetime | null" // optional, defaults to server time
|
||||
}
|
||||
```
|
||||
Returns the same shape as check-in with `checkOutAt` populated. `404` if there's no open
|
||||
(checked-in, not yet checked-out) visit for that route+store.
|
||||
|
||||
There is no endpoint to list a route's open/in-progress visits — check-out requires the caller to
|
||||
already know which store they checked into. A store's full visit history is available via
|
||||
`GET /api/stores/{id}/visit-history` (see [§6.9 Stores](#69-stores)), which reads from this same
|
||||
visit log.
|
||||
|
||||
---
|
||||
|
||||
### 6.7 Vehicles
|
||||
|
||||
Reference in New Issue
Block a user