Compare commits

..

11 Commits

Author SHA1 Message Date
DeepnaPooja a2d9ba4183 Add testbug.md 2026-09-01 17:21:31 +05:30
DeepnaPooja dd1d93d032 Add validations 2026-09-01 16:11:36 +05:30
DeepnaPooja 7cf272e311 Consolidate region management APIs 2026-09-01 15:46:35 +05:30
DeepnaPooja 82a32de966 add seed data 2026-09-01 15:46:35 +05:30
Kalana B. Thilakarathna 68b34b3fc3 Refactor store CRUD to pages, add map-based location picker
Refactored store creation and editing from modal sheets to dedicated pages (`/stores/new`, `/stores/[id]/edit`), and added a new store detail view page (`/stores/[id]`). Introduced a `LocationPicker` component using `react-leaflet` for map-based location selection, replacing manual latitude/longitude inputs. Updated navigation in the store list to use these pages. Improved UI and user feedback for store forms and details. Updated backend API and form logic to match the new flow. Added `leaflet`, `react-leaflet`, and types as dependencies. Temporarily relaxed store scope checks in `StoreService.cs`. Updated table actions and types for the new workflow.
2026-09-01 10:53:55 +05:30
DeepnaPooja 83167b5eb3 Merge pull request 'Feat/route sales mngt' (#17) from feat/route_sales_mngt into dev
Reviewed-on: #17
2026-08-31 07:38:42 +00:00
DeepnaPooja e67675a12a update docs 2026-08-31 13:00:49 +05:30
DeepnaPooja 2ecda623ac add route service tests 2026-08-31 13:00:42 +05:30
DeepnaPooja da9caa855a add sales visits ui 2026-08-31 13:00:33 +05:30
DeepnaPooja 5c4e1b590a add route visit management 2026-08-31 13:00:13 +05:30
DeepnaPooja 75b5035354 Merge pull request 'Feat/region mngt' (#16) from feat/region_mngt into dev
Reviewed-on: #16
2026-08-31 05:47:39 +00:00
63 changed files with 16682 additions and 623 deletions
@@ -0,0 +1,343 @@
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);
}
}
@@ -0,0 +1,85 @@
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);
}
}
+6
View File
@@ -60,6 +60,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
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)
{
@@ -400,5 +401,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.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);
}
}
+117
View File
@@ -0,0 +1,117 @@
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
}
);
}
}
+131
View File
@@ -0,0 +1,131 @@
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 }
);
}
}
@@ -0,0 +1,170 @@
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
}
);
}
}
@@ -0,0 +1,134 @@
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 }
);
}
}
+88
View File
@@ -0,0 +1,88 @@
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 }
);
}
}
@@ -11,17 +11,31 @@ public interface IRegionRepository
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);
@@ -38,31 +38,11 @@ public class RegionController(RegionService service) : ControllerBase
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/targets/{period}")]
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
//targets/budgets/agencies/tax-rule.
[HttpPut("{id:guid}/manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
{
var result = await service.SetTargetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/budgets/{period}")]
public async Task<IActionResult> SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct)
{
var result = await service.SetBudgetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/agencies")]
public async Task<IActionResult> MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct)
{
var result = await service.MapAgencyAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/tax-rule")]
public async Task<IActionResult> SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct)
{
var result = await service.SetTaxRuleAsync(id, request, ct);
var result = await service.ManageAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
@@ -79,4 +59,16 @@ public class RegionController(RegionService service) : ControllerBase
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();
}
}
+36 -9
View File
@@ -6,14 +6,6 @@ public record UpdateRegionRequest(string Name);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record SetRegionalTargetRequest(decimal TargetValue);
public record SetRegionalBudgetRequest(decimal BudgetAmount);
public record MapAgencyToRegionRequest(Guid AgencyUserId);
public record SetRegionalTaxRuleRequest(string RuleConfig);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
@@ -22,6 +14,23 @@ 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);
@@ -33,4 +42,22 @@ public record RegionDashboardResponse(
decimal? BudgetAmount,
int OrderCount,
decimal ActualSales,
DateTime? LastOrderDate);
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);
@@ -23,9 +23,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
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 && t.IsActive);
t.FK_RegionId == regionId && t.Period == period);
public async Task AddTargetAsync(Models.RegionalTarget target) =>
await context.RegionalTargets.AddAsync(target);
@@ -33,9 +37,22 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
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 && b.IsActive);
b.FK_RegionId == regionId && b.Period == period);
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
await context.RegionalBudgets.AddAsync(budget);
@@ -43,15 +60,45 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
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 && m.IsActive);
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 && r.IsActive);
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId);
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
await context.RegionalTaxRules.AddAsync(rule);
@@ -59,6 +106,17 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
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)
+159 -103
View File
@@ -5,15 +5,11 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalManagerRoleLevel = 50;
private const int RegionalAgencyRoleLevel = 60;
private async Task<bool> CanManageRegionAsync(Guid regionId)
{
if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true;
if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId);
}
// 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())
@@ -82,26 +78,97 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
return true;
}
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
// 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;
var target = await repo.GetTargetAsync(regionId, period);
RegionalTargetResponse? targetResponse = null;
RegionalBudgetResponse? budgetResponse = null;
RegionAgencyMapResponse? agencyMapResponse = null;
RegionalTaxRuleResponse? taxRuleResponse = null;
await uow.BeginAsync(ct);
try
{
if (target is null)
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
{
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
await repo.AddTargetAsync(target);
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);
}
else
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
{
target.TargetValue = request.TargetValue;
repo.UpdateTarget(target);
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
@@ -109,94 +176,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTargetResponse(regionId, period, target.TargetValue);
}
public async Task<RegionalBudgetResponse?> SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var budget = await repo.GetBudgetAsync(regionId, period);
await uow.BeginAsync(ct);
try
{
if (budget is null)
{
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount };
await repo.AddBudgetAsync(budget);
}
else
{
budget.BudgetAmount = request.BudgetAmount;
repo.UpdateBudget(budget);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount);
}
public async Task<RegionAgencyMapResponse?> MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId);
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null;
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId);
if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId };
await uow.BeginAsync(ct);
try
{
await repo.AddAgencyMapAsync(map);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
}
public async Task<RegionalTaxRuleResponse?> SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var rule = await repo.GetTaxRuleAsync(regionId);
await uow.BeginAsync(ct);
try
{
if (rule is null)
{
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
await repo.AddTaxRuleAsync(rule);
}
else
{
rule.RuleConfig = request.RuleConfig;
repo.UpdateTaxRule(rule);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
}
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
@@ -228,6 +209,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
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,
@@ -236,6 +219,79 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
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,4 +7,14 @@ 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);
}
@@ -44,4 +44,36 @@ public class RouteController(RouteService service) : ControllerBase
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);
}
}
+11 -1
View File
@@ -4,6 +4,16 @@ 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, bool IsActive, DateTime CreatedAt);
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);
@@ -22,4 +22,31 @@ 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();
}
+151 -6
View File
@@ -1,20 +1,50 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Route;
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow)
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
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.IsActive, r.CreatedAt))
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, 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.IsActive, route.CreatedAt);
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
}
public async Task<RouteResponse> CreateAsync(CreateRouteRequest request, CancellationToken ct = default)
@@ -38,7 +68,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return ToResponse(route);
}
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
@@ -61,7 +91,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return ToResponse(route);
}
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
@@ -84,7 +114,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return ToResponse(route);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -104,4 +134,119 @@ 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);
}
}
+9 -5
View File
@@ -5,7 +5,7 @@ using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo)
{
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,8 +209,12 @@ 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;
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
return [];
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();
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
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
@@ -0,0 +1,498 @@
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
+4
View File
@@ -12,5 +12,9 @@ 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; }
}
}
+26
View File
@@ -0,0 +1,26 @@
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; }
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Middleware;
@@ -144,7 +145,8 @@ builder.Services.AddCors(options =>
.AllowAnyHeader()
.AllowAnyMethod()));
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddSwaggerGen();
+4
View File
@@ -29,6 +29,10 @@ 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 = "...")]`
+4 -3
View File
@@ -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) | 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)) |
| `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. |
| — | `GET /api/audit-log?entityType=...&entityId=...` |
| — | `GET /api/anomaly-flags?status=Open` |
@@ -57,8 +57,9 @@ 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`) — depends on GPS check-in/out
data from [11_route_sales_management.md](./11_route_sales_management.md).
- 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.
- 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
+23 -13
View File
@@ -50,29 +50,39 @@ it's stored for a future pricing-rule integration.
| 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}/targets/{period}` | Implemented |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | Implemented |
| `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.
---
## Business Rules
- 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. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`)
is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching
`UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to
`mapAgencyToRegion`.
- `mapAgencyToRegion` additionally 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.
- ⚠️ **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.
---
+34 -3
View File
@@ -1,6 +1,6 @@
# 11 — Route & Sales Visit Management
**Status**: Not Started
**Status**: Partially Implemented
**Depends On**: 00 — Existing Gap Fixes
---
@@ -62,10 +62,41 @@ 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)?
need actual distance/priority-based computation (external mapping API)? — **Deferred.** The
endpoint is stubbed (`501 Not Implemented`) until this is decided.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
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/`).
+1 -1
View File
@@ -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 | Paginated. Empty list until Step 11 wiring lands |
| 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}/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 |
+1 -1
View File
@@ -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 |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
+14 -14
View File
@@ -55,7 +55,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba
| 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 | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 11 | Route & Sales Visit Management | Partially Implemented | 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) |
@@ -117,10 +117,10 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---|
| `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}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, 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. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
| `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. |
@@ -129,14 +129,14 @@ here — see `SRS_User_Management_Module.pdf`.
| 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. | 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. |
| `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. |
| `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. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
| `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`). |
| `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. |
+19
View File
@@ -0,0 +1,19 @@
# 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 |
|---|------|----------------------|--------------------------|----------|------|
| | | | | | |
@@ -0,0 +1,270 @@
'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>
)
}
@@ -0,0 +1,218 @@
'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}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RegionManageListClient } from './_components/region-manage-list-client'
export default function RegionManagePage() {
return <RegionManageListClient />
}
@@ -1,276 +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 {
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 { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type {
RegionResponse,
UserResponse,
RegionDashboardResponse,
RegionHierarchyResponse,
} from '@/types/api'
interface RegionManageSheetProps {
open: boolean
onClose: () => void
region: RegionResponse | null
}
function currentPeriod() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) {
const [period, setPeriod] = useState(currentPeriod())
const [targetValue, setTargetValue] = useState('')
const [budgetAmount, setBudgetAmount] = useState('')
const [ruleConfig, setRuleConfig] = useState('')
const [agencyUserId, setAgencyUserId] = useState('')
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
const [dashboard, setDashboard] = useState<RegionDashboardResponse | null>(null)
const [hierarchy, setHierarchy] = useState<RegionHierarchyResponse | null>(null)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
if (!open || !region) return
setPeriod(currentPeriod())
setTargetValue('')
setBudgetAmount('')
setRuleConfig('')
setAgencyUserId('')
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency')))
.catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' }))
regionsApi.hierarchy(region.id)
.then(setHierarchy)
.catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' }))
}, [open, region])
useEffect(() => {
if (!open || !region) return
regionsApi.dashboard(region.id, period)
.then(setDashboard)
.catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' }))
}, [open, region, period])
if (!region) return null
async function handleSetTarget() {
const value = parseFloat(targetValue)
if (Number.isNaN(value)) return
setSaving('target')
try {
await regionsApi.setTarget(region!.id, period, value)
toast.add({ title: 'Target updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetBudget() {
const value = parseFloat(budgetAmount)
if (Number.isNaN(value)) return
setSaving('budget')
try {
await regionsApi.setBudget(region!.id, period, value)
toast.add({ title: 'Budget updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetTaxRule() {
if (!ruleConfig.trim()) return
setSaving('tax-rule')
try {
await regionsApi.setTaxRule(region!.id, ruleConfig)
toast.add({ title: 'Tax rule updated', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleMapAgency() {
if (!agencyUserId) return
setSaving('agency')
try {
await regionsApi.mapAgency(region!.id, agencyUserId)
toast.add({ title: 'Agency mapped to region', type: 'success' })
setAgencyUserId('')
} catch (err) {
toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Manage {region.name}</DialogTitle>
<DialogDescription>
Targets, budgets, tax rules, agency mapping, and dashboard for this region.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="period">Period</Label>
<Input
id="period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-40"
/>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Regional Target</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 500000"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
/>
<Button type="button" onClick={handleSetTarget} disabled={saving === 'target'}>
{saving === 'target' ? 'Saving…' : 'Set Target'}
</Button>
</div>
{dashboard?.targetValue != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.targetValue}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label>Regional Budget</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 200000"
value={budgetAmount}
onChange={(e) => setBudgetAmount(e.target.value)}
/>
<Button type="button" onClick={handleSetBudget} disabled={saving === 'budget'}>
{saving === 'budget' ? 'Saving…' : 'Set Budget'}
</Button>
</div>
{dashboard?.budgetAmount != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.budgetAmount}</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Tax Rule Config (JSON)</Label>
<div className="flex gap-2">
<Input
placeholder='e.g. {"rate": 0.15}'
value={ruleConfig}
onChange={(e) => setRuleConfig(e.target.value)}
/>
<Button type="button" onClick={handleSetTaxRule} disabled={saving === 'tax-rule'}>
{saving === 'tax-rule' ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Map Regional Agency</Label>
<div className="flex gap-2">
<Select value={agencyUserId} onValueChange={(v) => setAgencyUserId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select an agency user">
{agencyUsers.find((u) => u.id === agencyUserId)?.fullName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{agencyUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={handleMapAgency} disabled={saving === 'agency'}>
{saving === 'agency' ? 'Saving…' : 'Map'}
</Button>
</div>
{agencyUsers.length === 0 && (
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Hierarchy</Label>
{hierarchy && hierarchy.routes.length > 0 ? (
<ul className="text-sm space-y-1">
{hierarchy.routes.map((r) => (
<li key={r.routeId}>
{r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">No routes under this region yet.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Dashboard ({period})</Label>
{dashboard ? (
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Orders: {dashboard.orderCount}</div>
<div>Actual Sales: {dashboard.actualSales}</div>
<div>Target: {dashboard.targetValue ?? '—'}</div>
<div>Budget: {dashboard.budgetAmount ?? '—'}</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Loading dashboard…</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -13,7 +13,6 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
import type { RegionResponse } from '@/types/api'
import { RegionSheet } from './region-sheet'
import { RegionManageSheet } from './region-manage-sheet'
export function RegionsClient() {
const [data, setData] = useState<RegionResponse[]>([])
@@ -25,9 +24,6 @@ export function RegionsClient() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RegionResponse | null>(null)
const [manageOpen, setManageOpen] = useState(false)
const [managing, setManaging] = useState<RegionResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
@@ -55,11 +51,6 @@ export function RegionsClient() {
setSheetOpen(true)
}
function openManage(region: RegionResponse) {
setManaging(region)
setManageOpen(true)
}
async function toggleActive(region: RegionResponse) {
try {
await regionsApi.update(region.id, { isActive: !region.isActive })
@@ -112,11 +103,6 @@ export function RegionsClient() {
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Manage targets, budgets & agencies',
onClick: openManage,
},
{
action: 'edit',
label: 'Edit region',
@@ -136,7 +122,7 @@ export function RegionsClient() {
},
],
},
}), [pageSize, openEdit, openManage, toggleActive])
}), [pageSize, openEdit, toggleActive])
return (
<>
@@ -170,12 +156,6 @@ export function RegionsClient() {
region={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<RegionManageSheet
open={manageOpen}
onClose={() => setManageOpen(false)}
region={managing}
/>
</>
)
}
@@ -0,0 +1,170 @@
'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,10 +9,11 @@ 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, getApiError, getPaginatedItems } from '@/lib/api/index'
import { routes as routesApi, users as usersApi, 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[]>([])
@@ -20,9 +21,12 @@ 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)
@@ -41,6 +45,16 @@ 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)
@@ -51,6 +65,11 @@ 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 })
@@ -95,6 +114,17 @@ 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',
@@ -125,6 +155,11 @@ export function RoutesClient() {
label: 'Edit route',
onClick: openEdit,
},
{
action: 'confirm',
label: 'Manage owner & stores',
onClick: openManage,
},
{
action: 'deactivate',
label: 'Deactivate route',
@@ -139,7 +174,7 @@ export function RoutesClient() {
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, userNameById, openEdit, openManage, toggleActive])
return (
<>
@@ -173,6 +208,13 @@ export function RoutesClient() {
route={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<RouteManageSheet
open={manageOpen}
onClose={() => setManageOpen(false)}
route={managing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,173 @@
'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>
)
}
@@ -0,0 +1,5 @@
import { SalesVisitsClient } from './_components/sales-visits-client'
export default function SalesVisitsPage() {
return <SalesVisitsClient />
}
@@ -0,0 +1,334 @@
'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>
)
}
@@ -0,0 +1,175 @@
'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>
)
}
@@ -0,0 +1,185 @@
'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='&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <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='&copy; <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,6 +1,7 @@
'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'
@@ -26,6 +27,12 @@ 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'
// 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'),
@@ -43,13 +50,11 @@ type FormValues = z.infer<typeof schema>
interface StoreSheetProps {
open: boolean
onClose: () => void
store: StoreResponse | null
store: StoreResponse
onSuccess: () => void
}
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({
@@ -67,53 +72,36 @@ 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) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
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,
}
)
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,
})
}
}, [open, store])
async function onSubmit(values: FormValues) {
try {
const body = {
await storesApi.update(store.id, {
...values,
segment: values.segment || undefined,
}
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' })
}
})
toast.add({ title: 'Store updated', type: 'success' })
onSuccess()
onClose()
} catch (err) {
@@ -123,12 +111,10 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Store' : 'Add Store'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
</DialogDescription>
<DialogTitle>Edit Store</DialogTitle>
<DialogDescription>Update the store details below.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -211,43 +197,32 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
)}
/>
<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}
/>
)}
/>
{/* 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>
<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}
/>
<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>
)}
/>
</div>
<FormField
control={form.control}
@@ -309,7 +284,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Store'}
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,69 @@
'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,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -13,6 +14,7 @@ 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'
@@ -21,6 +23,7 @@ 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)
@@ -29,6 +32,8 @@ 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)
@@ -57,6 +62,11 @@ 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 })
@@ -120,10 +130,20 @@ export function StoresClient() {
],
rowActions: {
actions: () => [
{
action: 'view',
label: 'View store',
onClick: (store) => router.push(`/stores/${store.id}`),
},
{
action: 'edit',
label: 'Edit store',
onClick: openEdit,
onClick: (store) => router.push(`/stores/${store.id}/edit`),
},
{
action: 'confirm',
label: 'View visit history',
onClick: openHistory,
},
{
action: 'deactivate',
@@ -139,7 +159,7 @@ export function StoresClient() {
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, openEdit, openHistory, toggleActive])
return (
<>
@@ -150,7 +170,7 @@ export function StoresClient() {
Retail store accounts, credit limits, and KYC status
</p>
</div>
<Button onClick={openCreate}>
<Button onClick={() => router.push('/stores/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Store
</Button>
@@ -173,6 +193,12 @@ export function StoresClient() {
store={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<StoreVisitHistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
store={viewingHistory}
/>
</>
)
}
@@ -0,0 +1,294 @@
'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>
)
}
@@ -4,6 +4,7 @@ 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,6 +6,7 @@ import {
Cancel01Icon,
CheckmarkCircle02Icon,
Delete02Icon,
EyeIcon,
PencilEdit01Icon,
} from "@hugeicons/core-free-icons"
@@ -19,6 +20,7 @@ const ACTION_ICONS = {
confirm: CheckmarkCircle02Icon,
deactivate: Cancel01Icon,
edit: PencilEdit01Icon,
view: EyeIcon,
} as const
type RowActionKind = keyof typeof ACTION_ICONS
+2
View File
@@ -73,6 +73,7 @@ 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' },
],
},
{
@@ -137,6 +138,7 @@ 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,6 +51,7 @@ const MOCK_PERMISSIONS: Record<Role, string[]> = {
'routes.view',
'rides.view',
'driver_profiles.view',
'sales_visits.checkin',
],
}
+26 -13
View File
@@ -2,13 +2,13 @@ import { api } from '@/lib/api'
import type {
PaginatedResponse,
RegionResponse,
RegionalTargetResponse,
RegionalBudgetResponse,
RegionAgencyMapResponse,
RegionalTaxRuleResponse,
ManageRegionResponse,
RegionHierarchyResponse,
RegionDashboardResponse,
RegionManageEntryResponse,
RegionManageEntryType,
RouteResponse,
RouteVisitResponse,
VehicleResponse,
WarehouseResponse,
VehicleType,
@@ -103,17 +103,18 @@ export const regions = {
remove: (id: string) =>
api.delete(`/regions/${id}`),
setTarget: (id: string, period: string, targetValue: number) =>
api.put<RegionalTargetResponse>(`/regions/${id}/targets/${period}`, { targetValue }).then(r => r.data),
// 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),
setBudget: (id: string, period: string, budgetAmount: number) =>
api.put<RegionalBudgetResponse>(`/regions/${id}/budgets/${period}`, { budgetAmount }).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),
mapAgency: (id: string, agencyUserId: string) =>
api.post<RegionAgencyMapResponse>(`/regions/${id}/agencies`, { agencyUserId }).then(r => r.data),
setTaxRule: (id: string, ruleConfig: string) =>
api.put<RegionalTaxRuleResponse>(`/regions/${id}/tax-rule`, { ruleConfig }).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),
@@ -148,6 +149,18 @@ 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 ─────────────────────────────────────────────────────────────────
+4
View File
@@ -5,6 +5,7 @@ import type {
KycDocumentResponse,
OutstandingBalanceResponse,
StoreDashboardResponse,
StoreVisitHistoryItem,
KycStatus,
} from '@/types/api'
@@ -58,4 +59,7 @@ 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),
}
+49
View File
@@ -12,14 +12,17 @@
"@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-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",
@@ -1980,6 +1983,17 @@
"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",
@@ -2384,6 +2398,12 @@
"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",
@@ -2398,6 +2418,15 @@
"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",
@@ -6621,6 +6650,12 @@
"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",
@@ -8235,6 +8270,20 @@
"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",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/recast": {
"version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
+3
View File
@@ -16,14 +16,17 @@
"@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-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",
+42
View File
@@ -81,10 +81,28 @@ 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
@@ -107,6 +125,14 @@ export interface RegionalTaxRuleResponse {
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
@@ -127,6 +153,22 @@ export interface RegionDashboardResponse {
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 ───────────────────────────────────────────────────
+115 -42
View File
@@ -506,54 +506,44 @@ Array of:
}
```
**Beyond CRUD** — targets, budgets, agency mapping, tax rules, hierarchy, and dashboard. These are
not gated by the `regions.*` permission codes above; access is checked by role level in
`RegionService`: `RegionalManager` (level 50) can act only on a region they have scope over (via
`UserRegionScope`), `RegionalAgency` (level 60) can act on any region.
**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}/targets/{period}` | Set the sales target for a region for a period (`period` = `yyyy-MM`) |
| `PUT` | `/api/regions/{id}/budgets/{period}` | Set the budget for a region for a period |
| `POST` | `/api/regions/{id}/agencies` | Map an existing user (holding the `RegionalAgency` role) to this region |
| `PUT` | `/api/regions/{id}/tax-rule` | Set/replace the region's tax rule config |
| `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 |
**Set target request / response:**
**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}/targets/{period}
{ "targetValue": 500000 }
```
```json
{ "regionId": "guid", "period": "2026-08", "targetValue": 500000 }
// 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
}
```
**Set budget request / response:**
**Manage response** — each field is `null` if that sub-action wasn't requested:
```json
// PUT /api/regions/{id}/budgets/{period}
{ "budgetAmount": 200000 }
```
```json
{ "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 }
```
**Map agency request / response:**
```json
// POST /api/regions/{id}/agencies
{ "agencyUserId": "guid" } // must be a User with the RegionalAgency role
```
```json
{ "regionId": "guid", "agencyUserId": "guid" }
```
**Set tax rule request / response:**
```json
// PUT /api/regions/{id}/tax-rule
{ "ruleConfig": "{\"rate\": 0.15}" } // arbitrary JSON stored as a string; not yet consumed by pricing
```
```json
{ "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" }
{
"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:**
@@ -576,13 +566,20 @@ not gated by the `regions.*` permission codes above; access is checked by role l
"budgetAmount": 200000, // null if no budget set for the period
"orderCount": 42,
"actualSales": 187500.00,
"lastOrderDate": "datetime | null"
"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 four of these endpoints return `404` if the region doesn't exist, the caller lacks the
required region scope, or (for agency mapping) the target user doesn't hold the `RegionalAgency`
role — the response body carries no distinction between "not found" and "forbidden".
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.)
---
@@ -619,6 +616,7 @@ role — the response body carries no distinction between "not found" and "forbi
"startPoint": "string",
"endPoint": "string",
"length": 12.5,
"ownerUserId": "guid | null",
"isActive": true,
"createdAt": "datetime"
}
@@ -632,6 +630,81 @@ role — the response body carries no distinction between "not found" and "forbi
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