Compare commits

...

21 Commits

Author SHA1 Message Date
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
DeepnaPooja c0432a448f Document route-to-region assignment and applied migration
Notes the new PUT /api/routes/{id}/region endpoint as a stand-in for
the still-blocked assignAreaToRegion, and removes the stale "migration
pending" caveat now that AddRegionManagement has been applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:25 +05:30
DeepnaPooja bfbfe352ee Add dedicated endpoint to assign a route to a region
PUT /api/routes/{id}/region assigns/reassigns an existing route to a
region directly, validating the target region exists and is active.
Stands in for the blocked assignAreaToRegion until an Area entity
exists between Region and Route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:15 +05:30
DeepnaPooja 320da0e1c9 Add and apply EF Core migration for region management tables
Creates RegionAgencyMaps, RegionalBudgets, RegionalTargets, and
RegionalTaxRules with their unique indexes and foreign keys, and
applies the migration to the shared MySQL database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 10:38:59 +05:30
DeepnaPooja f0d7f939ef Update mainPlan.md status table for Region Management
The summary table still said "Not Started" for step 10 after the
detailed section below it was updated to reflect the implemented
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:53:59 +05:30
DeepnaPooja 8b84c8aeb3 Document the implemented region management endpoints
Updates mainPlan.md's Region Management table, 10_region_management.md's
status/open-questions/implementation-notes sections, and
BACKEND_API_REFERENCE.md's Regions section to reflect the newly added
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints, and
flags that the EF Core migration for the new tables is still pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:51:46 +05:30
DeepnaPooja 9febae6d63 Add region management dialog for targets, budgets, tax rules & agencies
RegionManageSheet lets a manager set a region's target/budget for a
period, save a tax rule config, map a RegionalAgency user to the
region, and view its route/store hierarchy and dashboard stats. Wired
into the regions table as a new row action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:46:05 +05:30
DeepnaPooja df27d58089 Add frontend types and API client methods for region management
Adds RegionalTargetResponse, RegionalBudgetResponse,
RegionAgencyMapResponse, RegionalTaxRuleResponse,
RegionHierarchyResponse, and RegionDashboardResponse types, plus
setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard methods
on the regions API client, mirroring the new backend endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:55 +05:30
DeepnaPooja 4e12e9f8f1 Update RegionServiceTests for the new RegionService constructor
RegionService now depends on ICurrentUserService and IScopeAccessService
for the target/budget/tax-rule/agency scope checks; update the existing
CRUD test setup to mock them accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:43 +05:30
DeepnaPooja 39eab2e3a2 Add region target/budget/tax-rule/agency-map/hierarchy/dashboard APIs
Extends RegionService, RegionRepository, and RegionController with the
endpoints from mainPlan.md sec 2 beyond plain CRUD: setRegionalTarget,
setRegionalBudget, mapAgencyToRegion, setRegionalTaxRule,
getRegionHierarchy, and getRegionDashboard. Target/budget/tax-rule
mutation is gated to users with region scope at RegionalManager level
or RegionalAgency level across all regions; mapAgencyToRegion links an
existing User holding the RegionalAgency role rather than introducing
a separate agency entity, matching the convention already used by
Claim.FK_AgencyUserId elsewhere in the codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:35 +05:30
DeepnaPooja 889ca0795d Add data model for region targets, budgets, agency mapping, tax rules
Introduces RegionalTarget, RegionalBudget, RegionAgencyMap, and
RegionalTaxRule entities plus their DbSets and EF constraints, laying
the groundwork for the "beyond CRUD" region management features.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:44:12 +05:30
Kalana B. Thilakarathna a7801a2bc2 Merge branch 'production' into dev 2026-08-29 21:03:56 +05:30
Kalana B. Thilakarathna f4db83654c Implement GRN and Purchase Order sheets with form handling and data fetching 2026-08-29 21:03:31 +05:30
Kalana B. Thilakarathna d414ec8e38 Merge branch 'dev' into production 2026-08-26 20:20:00 +05:30
Kalana B. Thilakarathna dc7e7a70fc Add FlatPaymentController with RESTful payment endpoints
Introduced FlatPaymentController under Distribution.Features.Payment, providing GET and POST endpoints at /api/payments. Supports listing, retrieving, and creating payments with filtering and error handling. Business logic is delegated to PaymentService.
2026-08-26 20:18:58 +05:30
Kalana B. Thilakarathna 20b82a2734 Merge branch 'dev' into production 2026-08-25 23:48:06 +05:30
DeepnaPooja d6cd9ccd08 Merge pull request 'Dev' (#15) from dev into production
Reviewed-on: #15
2026-08-25 17:24:08 +00:00
81 changed files with 9655 additions and 118 deletions
@@ -1,4 +1,5 @@
using Distribution.Features.Region;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
@@ -10,11 +11,14 @@ public class RegionServiceTests
{
private readonly Mock<IRegionRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly RegionService _sut;
public RegionServiceTests()
{
_sut = new RegionService(_repo.Object, _uow.Object);
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
private static Region ActiveRegion(string name = "North") =>
@@ -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);
}
}
+27
View File
@@ -56,6 +56,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Claim> Claims { get; set; }
public DbSet<SupplierClaim> SupplierClaims { get; set; }
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
public DbSet<RegionalTarget> RegionalTargets { get; set; }
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -373,6 +378,28 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
modelBuilder.Entity<RegionalBudget>()
.HasIndex(b => new { b.FK_RegionId, b.Period })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasOne(m => m.AgencyUser)
.WithMany()
.HasForeignKey(m => m.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTaxRule>()
.HasIndex(r => r.FK_RegionId)
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -19,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
@@ -1,24 +0,0 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverComplianceConfig;
[ApiController]
[Route("api/driver-compliance-config")]
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var result = await service.GetAsync();
return result is null ? NoContent() : Ok(result);
}
[HttpPut]
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var result = await service.UpsertAsync(request, userId, ct);
return Ok(result);
}
}
@@ -1,10 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
[ApiController]
[Route("api/driver-profiles")]
public class DriverProfileController(DriverProfileService service) : ControllerBase
public class DriverProfileController(
DriverProfileService service,
DriverComplianceConfigService complianceService,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
@@ -23,8 +28,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
[HttpPost]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
@@ -40,4 +56,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
var success = await service.DeactivateAsync(id, ct);
return success ? NoContent() : NotFound();
}
[HttpGet("compliance")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
return result is null ? NoContent() : Ok(result);
}
[HttpPut("compliance")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var result = await complianceService.UpsertAsync(request, userId, ct);
return Ok(result);
}
}
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
DateTime CreatedAt);
public record CreateDriverProfileRequest(
Guid FK_UserId,
Guid UserId,
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
@@ -1,9 +1,10 @@
using Distribution.Features.Stock;
using Distribution.Features.User;
using Distribution.Infra.UoW;
namespace Distribution.Features.DriverProfile;
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
{
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
{
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
{
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
var user = await userRepo.GetByIdAsync(request.UserId);
if (user is null)
throw new KeyNotFoundException($"User {request.UserId} not found");
var existing = await repo.GetByUserIdAsync(request.UserId);
if (existing is not null && existing.IsActive)
throw new InvalidOperationException("An active driver profile already exists for this user");
var profile = new global::Distribution.Models.DriverProfile
{
FK_UserId = request.FK_UserId,
FK_UserId = request.UserId,
LicenseNumber = request.LicenseNumber,
LicenseExpiry = request.LicenseExpiry,
MedicalClearanceExpiry = request.MedicalClearanceExpiry
@@ -6,6 +6,16 @@ namespace Distribution.Features.Grn;
[ApiController]
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
return Ok(result);
}
[HttpGet("api/goods-receipts/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -13,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/goods-receipts")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
+2
View File
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
await context.GoodsReceipts
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.CountAsync();
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
+7
View File
@@ -19,6 +19,13 @@ public class GrnService(
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
var total = await repo.GetCountAsync(purchaseOrderId);
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
}
public async Task<GrnResponse?> GetByIdAsync(Guid id)
{
var gr = await repo.GetByIdAsync(id);
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
public interface IGrnRepository
{
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
Task<int> GetCountAsync(Guid? purchaseOrderId);
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.GoodsReceipt entity);
@@ -100,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -225,6 +225,98 @@ public class LoadingSheetService(
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/payments")]
public class FlatPaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? storeId,
[FromQuery] Guid? saleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
{
try
{
var inner = new CreatePaymentRequest(request.Amount, request.Method);
var result = await service.CreateAsync(request.SaleId, inner, ct);
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -4,6 +4,9 @@ namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<Models.Payment?> GetByIdAsync(Guid id);
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
@@ -6,6 +6,26 @@ namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
@@ -1,4 +1,5 @@
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -14,6 +15,19 @@ public class PaymentService(
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
{
var total = await repo.GetCountAsync(storeId, saleId);
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
{
var payment = await repo.GetByIdAsync(id);
return payment is null ? null : ToResponse(payment);
}
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
@@ -7,4 +7,26 @@ public interface IRegionRepository
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
Task AddTargetAsync(Models.RegionalTarget target);
void UpdateTarget(Models.RegionalTarget target);
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
Task AddBudgetAsync(Models.RegionalBudget budget);
void UpdateBudget(Models.RegionalBudget budget);
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
void UpdateTaxRule(Models.RegionalTaxRule rule);
Task<int?> GetUserRoleLevelAsync(Guid userId);
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -37,4 +37,46 @@ public class RegionController(RegionService service) : ControllerBase
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/targets/{period}")]
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest 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);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/hierarchy")]
public async Task<IActionResult> GetHierarchy(Guid id)
{
var result = await service.GetHierarchyAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
}
@@ -5,3 +5,32 @@ public record CreateRegionRequest(string Name);
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);
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
public record RegionDashboardResponse(
Guid RegionId,
string Period,
decimal? TargetValue,
decimal? BudgetAmount,
int OrderCount,
decimal ActualSales,
DateTime? LastOrderDate);
@@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
entity.IsActive = false;
context.Regions.Update(entity);
}
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
await context.RegionalTargets.FirstOrDefaultAsync(t =>
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
public async Task AddTargetAsync(Models.RegionalTarget target) =>
await context.RegionalTargets.AddAsync(target);
public void UpdateTarget(Models.RegionalTarget target) =>
context.RegionalTargets.Update(target);
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
await context.RegionalBudgets.AddAsync(budget);
public void UpdateBudget(Models.RegionalBudget budget) =>
context.RegionalBudgets.Update(budget);
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive);
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
await context.RegionAgencyMaps.AddAsync(map);
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
await context.RegionalTaxRules.AddAsync(rule);
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
context.RegionalTaxRules.Update(rule);
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
await context.Users
.Where(u => u.Id == userId && u.IsActive)
.Select(u => (int?)u.Role.Level)
.FirstOrDefaultAsync();
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders
join s in context.Stores on o.FK_StoreId equals s.Id
join r in context.Routes on s.FK_RouteId equals r.Id
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
select o).ToListAsync();
}
+169 -1
View File
@@ -1,9 +1,20 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
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);
}
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
@@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
}
return true;
}
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest 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);
await uow.BeginAsync(ct);
try
{
if (target is null)
{
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
await repo.AddTargetAsync(target);
}
else
{
target.TargetValue = request.TargetValue;
repo.UpdateTarget(target);
}
await uow.CommitAsync(ct);
}
catch
{
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);
}
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var routes = await repo.GetRoutesAsync(regionId);
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
var routeItems = routes
.Select(r => new RegionHierarchyRouteItem(
r.Id,
r.RouteName,
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
.ToList();
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
}
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var normalizedPeriod = periodStart.ToString("yyyy-MM");
var periodEndExclusive = periodStart.AddMonths(1);
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
return new RegionDashboardResponse(
regionId,
normalizedPeriod,
target?.TargetValue,
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
}
}
@@ -59,6 +59,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
@@ -71,6 +72,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
@@ -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);
}
@@ -31,10 +31,49 @@ public class RouteController(RouteService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/region")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPost("{id:guid}/stores")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/owner")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/optimize")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
var result = await service.RecordCheckInAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/visits/check-out")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
+13 -1
View File
@@ -4,4 +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();
}
+173 -5
View File
@@ -1,20 +1,50 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Route;
public class RouteService(IRouteRepository repo, 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, IUnitOfWork uow)
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,30 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
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)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
var region = await regionRepo.GetByIdAsync(request.RegionId);
if (region is null || !region.IsActive) return null;
route.FK_RegionId = request.RegionId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -81,4 +134,119 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
}
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);
}
}
@@ -33,4 +33,15 @@ public class SaleController(SaleService service) : ControllerBase
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+20
View File
@@ -119,6 +119,26 @@ public class SaleService(
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
@@ -93,4 +93,11 @@ public class StoreController(StoreService service) : ControllerBase
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/kyc-status")]
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
return result is null ? NotFound() : Ok(result);
}
}
+2
View File
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
+7 -3
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;
@@ -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,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRegionManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RegionAgencyMaps",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionAgencyMaps", x => x.Id);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalBudgets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BudgetAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalBudgets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalBudgets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTargets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetValue = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTargets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTargets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTaxRules",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
table: "RegionAgencyMaps",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
table: "RegionAgencyMaps",
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalBudgets_FK_RegionId_Period",
table: "RegionalBudgets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTargets_FK_RegionId_Period",
table: "RegionalTargets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTaxRules_FK_RegionId",
table: "RegionalTaxRules",
column: "FK_RegionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RegionAgencyMaps");
migrationBuilder.DropTable(
name: "RegionalBudgets");
migrationBuilder.DropTable(
name: "RegionalTargets");
migrationBuilder.DropTable(
name: "RegionalTaxRules");
}
}
}
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");
}
}
}
@@ -851,6 +851,121 @@ namespace Distribution.Migrations
b.ToTable("Regions");
});
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_AgencyUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("FK_AgencyUserId");
b.HasIndex("FK_RegionId", "FK_AgencyUserId")
.IsUnique();
b.ToTable("RegionAgencyMaps");
});
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("BudgetAmount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Period")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("FK_RegionId", "Period")
.IsUnique();
b.ToTable("RegionalBudgets");
});
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Period")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<decimal>("TargetValue")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_RegionId", "Period")
.IsUnique();
b.ToTable("RegionalTargets");
});
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("RuleConfig")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("FK_RegionId")
.IsUnique();
b.ToTable("RegionalTaxRules");
});
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.Property<Guid>("Id")
@@ -1000,6 +1115,9 @@ namespace Distribution.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("FK_OwnerUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
@@ -1019,11 +1137,60 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("FK_OwnerUserId");
b.HasIndex("FK_RegionId");
b.ToTable("Routes");
});
modelBuilder.Entity("Distribution.Models.RouteVisitLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CheckInAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CheckInLatitude")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CheckInLongitude")
.HasColumnType("decimal(65,30)");
b.Property<DateTime?>("CheckOutAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RouteId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_UserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsDeviationFlagged")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("FK_RouteId");
b.HasIndex("FK_StoreId");
b.HasIndex("FK_UserId");
b.ToTable("RouteVisitLogs");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.Property<Guid>("Id")
@@ -2185,6 +2352,58 @@ namespace Distribution.Migrations
b.Navigation("PurchaseReturn");
});
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
{
b.HasOne("Distribution.Models.User", "AgencyUser")
.WithMany()
.HasForeignKey("FK_AgencyUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AgencyUser");
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
@@ -2217,15 +2436,48 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Route", b =>
{
b.HasOne("Distribution.Models.User", "OwnerUser")
.WithMany()
.HasForeignKey("FK_OwnerUserId");
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OwnerUser");
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RouteVisitLog", b =>
{
b.HasOne("Distribution.Models.Route", "Route")
.WithMany()
.HasForeignKey("FK_RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.User", "User")
.WithMany()
.HasForeignKey("FK_UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Route");
b.Navigation("Store");
b.Navigation("User");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionAgencyMap : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalBudget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal BudgetAmount { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTarget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal TargetValue { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTaxRule : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string RuleConfig { get; set; }
}
}
+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; }
}
}
+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
+48 -19
View File
@@ -1,6 +1,6 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Not Started
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
**Depends On**: 00 — Existing Gap Fixes
---
@@ -24,48 +24,77 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
### Regional Target / Budget
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
setting the same period again updates it in place rather than inserting a duplicate.
### Agency-Region Map
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
the same convention already used by `Claim.FK_AgencyUserId` (§18).
### Regional Tax Rule
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
it's stored for a future pricing-rule integration.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
| 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 |
| `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`.
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
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.
---
## Open Questions
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
queries become a performance concern at scale.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
wired into the regions table's row actions.
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
all exist.
+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` |
+19 -19
View File
@@ -54,8 +54,8 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Partially Implemented | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
@@ -116,27 +116,27 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
| `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). |
| `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. |
### 3. Route Management
| Function | What it does | Status |
|---|---|---|
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
| `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. |
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. |
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Implemented — `POST /api/routes/{id}/stores` bulk-replaces `FK_RouteId` for the given store ids (all-or-nothing; 404 if any id doesn't exist). |
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Implemented — `PUT /api/routes/{id}/owner`, backed by the new `Route.FK_OwnerUserId` field, distinct from the existing `UserRouteScope` grant. |
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Stubbed — `POST /api/routes/{id}/optimize` always returns `501 Not Implemented`; no distance/priority computation exists yet (see open question in [11_route_sales_management.md](./11_route_sales_management.md)). |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Implemented — `POST /api/routes/{id}/visits/check-in`, backed by the new `RouteVisitLog` entity. Sets `IsDeviationFlagged` via a basic Haversine distance check (>500m) against the store's `Latitude`/`Longitude`; does not yet call `sendRouteDeviationAlert` (§9, unimplemented). |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Implemented — `POST /api/routes/{id}/visits/check-out`, closes the matching open `RouteVisitLog` row (404 if none). |
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — metrics deferred, out of scope for the current pass. |
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Implemented — same endpoint as `assignRouteToSalesRep` (`PUT /api/routes/{id}/owner`); the doc treats these as one action, distinguished only by caller intent. |
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented — metrics deferred, out of scope for the current pass. |
| `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. |
@@ -70,7 +70,7 @@ export function CategoriesClient() {
width: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.subCategories.length} sub-categories
{(row.subCategories ?? []).length} sub-categories
</span>
),
},
@@ -1,6 +1,8 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, PencilEdit01Icon, Delete01Icon, Tick01Icon, Cancel01Icon } 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'
@@ -17,7 +19,7 @@ import {
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { categories as categoriesApi, getApiError } from '@/lib/api/index'
import type { CategoryResponse } from '@/types/api'
import type { CategoryResponse, SubCategoryResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -36,6 +38,13 @@ interface CategorySheetProps {
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
const isEdit = !!category
const [subCategories, setSubCategories] = useState<SubCategoryResponse[]>([])
const [isAdding, setIsAdding] = useState(false)
const [addingName, setAddingName] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [busyId, setBusyId] = useState<string | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
@@ -48,6 +57,11 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
? { name: category.name, isActive: category.isActive }
: { name: '', isActive: true }
)
setSubCategories(category?.subCategories ?? [])
setIsAdding(false)
setAddingName('')
setEditingId(null)
setEditingName('')
}
}, [open, category])
@@ -67,6 +81,52 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
}
}
async function handleAddSubCategory() {
const name = addingName.trim()
if (!name) return
setBusyId('new')
try {
const created = await categoriesApi.createSubCategory(category!.id, { name, isActive: true })
setSubCategories(prev => [...prev, created as SubCategoryResponse])
setAddingName('')
setIsAdding(false)
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to add sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
async function handleSaveEdit(subId: string) {
const name = editingName.trim()
if (!name) return
setBusyId(subId)
try {
await categoriesApi.updateSubCategory(category!.id, subId, { name })
setSubCategories(prev => prev.map(s => s.id === subId ? { ...s, name } : s))
setEditingId(null)
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to update sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
async function handleDelete(subId: string) {
setBusyId(subId)
try {
await categoriesApi.deleteSubCategory(subId)
setSubCategories(prev => prev.filter(s => s.id !== subId))
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to delete sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
@@ -116,6 +176,127 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
/>
</FieldGroup>
{isEdit && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Sub-categories</span>
{!isAdding && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
>
<HugeiconsIcon icon={Add01Icon} size={14} />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-0.5 rounded-md border bg-muted/30 px-1 py-1">
{subCategories.length === 0 && !isAdding && (
<p className="px-2 py-2 text-sm text-muted-foreground">No sub-categories yet.</p>
)}
{subCategories.map((sub) => (
<div key={sub.id} className="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/60">
{editingId === sub.id ? (
<>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(sub.id) }
if (e.key === 'Escape') setEditingId(null)
}}
className="h-7 text-sm"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={busyId === sub.id}
onClick={() => handleSaveEdit(sub.id)}
>
<HugeiconsIcon icon={Tick01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setEditingId(null)}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} />
</Button>
</>
) : (
<>
<span className="flex-1 text-sm">{sub.name}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => { setEditingId(sub.id); setEditingName(sub.name) }}
>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
disabled={busyId === sub.id}
onClick={() => handleDelete(sub.id)}
>
<HugeiconsIcon icon={Delete01Icon} size={14} />
</Button>
</>
)}
</div>
))}
{isAdding && (
<div className="flex items-center gap-1 rounded px-2 py-1">
<Input
value={addingName}
onChange={(e) => setAddingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddSubCategory() }
if (e.key === 'Escape') { setIsAdding(false); setAddingName('') }
}}
placeholder="Sub-category name"
className="h-7 text-sm"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={busyId === 'new'}
onClick={handleAddSubCategory}
>
<HugeiconsIcon icon={Tick01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => { setIsAdding(false); setAddingName('') }}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} />
</Button>
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
@@ -0,0 +1,276 @@
'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,6 +13,7 @@ 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[]>([])
@@ -24,6 +25,9 @@ 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 {
@@ -51,6 +55,11 @@ 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 })
@@ -103,6 +112,11 @@ export function RegionsClient() {
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Manage targets, budgets & agencies',
onClick: openManage,
},
{
action: 'edit',
label: 'Edit region',
@@ -122,7 +136,7 @@ export function RegionsClient() {
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, openEdit, openManage, toggleActive])
return (
<>
@@ -156,6 +170,12 @@ 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)}
/>
</>
)
}
@@ -29,6 +29,7 @@ import {
routes as routesApi,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api'
@@ -49,9 +50,6 @@ interface RideSheetProps {
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
@@ -91,10 +89,10 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
warehousesApi.list({ pageSize: 200 }),
])
.then(([v, d, r, w]) => {
setVehiclesList(ensureItems(v))
setDriversList(ensureItems(d))
setRoutesList(ensureItems(r))
setWarehousesList(ensureItems(w))
setVehiclesList(getPaginatedItems(v))
setDriversList(getPaginatedItems(d))
setRoutesList(getPaginatedItems(r))
setWarehousesList(getPaginatedItems(w))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -1,12 +1,15 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
import { GrnSheet } from './grn-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -19,6 +22,7 @@ export function GrnClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -101,6 +105,10 @@ export function GrnClient() {
Stock received into a warehouse against a purchase order
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Record Receipt
</Button>
</div>
<DataTable
@@ -113,6 +121,12 @@ export function GrnClient() {
setPageSize(p.pageSize)
}}
/>
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useEffect, useState } from 'react'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { PurchaseOrderResponse, WarehouseResponse } from '@/types/api'
const grnItemSchema = z.object({
purchaseOrderItemId: z.string().min(1),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
})
const schema = z.object({
purchaseOrderId: z.string().min(1, 'Purchase order is required'),
warehouseId: z.string().min(1, 'Warehouse is required'),
receivedAt: z.string().min(1, 'Received date is required'),
items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
})
type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
purchaseOrderId: '',
warehouseId: '',
receivedAt: todayIso(),
items: [],
},
})
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
// Load reference data when sheet opens
useEffect(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), items: [] })
setSelectedPO(null)
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
])
.then(([pos, ws]) => {
// Only POs that can still receive goods
const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
setWarehousesList(getPaginatedItems(ws))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
form.setValue('purchaseOrderId', poId, { shouldValidate: true })
if (!poId) {
setSelectedPO(null)
replace([])
return
}
setLoadingPO(true)
try {
const po = await purchaseOrdersApi.get(poId)
setSelectedPO(po)
// Pre-fill items: remaining quantity for each non-cancelled line
const lineItems = (po.items ?? [])
.filter(item => !item.isCancelled)
.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
}))
replace(lineItems)
} catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
} finally {
setLoadingPO(false)
}
}
async function onSubmit(values: FormValues) {
try {
await grnApi.create({
purchaseOrderId: values.purchaseOrderId,
warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items: values.items,
})
toast.add({ title: 'Goods receipt recorded', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Record Goods Receipt</DialogTitle>
<DialogDescription>
Record stock received into a warehouse against a purchase order.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
<FieldGroup>
<FormField
control={form.control}
name="purchaseOrderId"
label="Purchase Order"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={v => handlePOChange(v ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Select purchase order..." />
</SelectTrigger>
<SelectContent>
{eligiblePOs.map(po => (
<SelectItem key={po.id} value={po.id}>
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="receivedAt"
label="Received Date"
required
render={({ field, invalid }) => (
<Input
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
</FieldGroup>
{/* Line items — populated from the selected PO */}
{selectedPO && (
<>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Line Items</p>
{loadingPO ? (
<p className="text-sm text-muted-foreground">Loading items…</p>
) : poItems.length === 0 ? (
<p className="text-sm text-muted-foreground">No receivable items on this order.</p>
) : (
<>
{/* Header row */}
<div className="grid grid-cols-[1fr_100px_100px] gap-3 px-1">
<p className="text-xs font-medium text-muted-foreground">Item</p>
<p className="text-xs font-medium text-muted-foreground">Accepted</p>
<p className="text-xs font-medium text-muted-foreground">Rejected</p>
</div>
{fields.map((f, index) => {
const poItem = poItems[index]
return (
<div key={f.id} className="grid grid-cols-[1fr_100px_100px] items-start gap-3 rounded-xl border px-3 py-3">
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-sm font-medium">{poItem?.itemName}</span>
<span className="text-xs text-muted-foreground">
{poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity}
</span>
</div>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
)
})}
</>
)}
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,262 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import {
purchaseOrders as purchaseOrdersApi,
suppliers as suppliersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, SupplierResponse } from '@/types/api'
const poItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
})
const schema = z.object({
supplierId: z.string().min(1, 'Supplier is required'),
items: z.array(poItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface PurchaseOrderSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function defaultItem() {
return { itemId: '', quantity: 1, unitCost: 0 }
}
export function PurchaseOrderSheet({ open, onClose, onSuccess }: PurchaseOrderSheetProps) {
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { supplierId: '', items: [defaultItem()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const watchedItems = form.watch('items')
const itemsById = useMemo(() => new Map(itemsList.map(i => [i.id, i])), [itemsList])
const totalCost = useMemo(
() => watchedItems.reduce((sum, i) => sum + (Number(i.quantity) || 0) * (Number(i.unitCost) || 0), 0),
[watchedItems]
)
useEffect(() => {
if (!open) return
form.reset({ supplierId: '', items: [defaultItem()] })
Promise.all([
suppliersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
])
.then(([s, it]) => {
setSuppliersList(getPaginatedItems(s))
setItemsList(getPaginatedItems(it))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
async function onSubmit(values: FormValues) {
try {
await purchaseOrdersApi.create(values)
toast.add({ title: 'Purchase order created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create purchase order', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Create Purchase Order</DialogTitle>
<DialogDescription>Issue a new purchase order to a supplier.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6">
<div className="grid min-h-0 gap-6 lg:grid-cols-3">
{/* Left — supplier + summary */}
<FieldGroup className="lg:col-span-2">
<FormField
control={form.control}
name="supplierId"
label="Supplier"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{suppliersList.map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="rounded-2xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Order Summary</p>
<p className="text-sm text-muted-foreground">{fields.length} line item{fields.length !== 1 ? 's' : ''}</p>
</div>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total Cost</p>
<p className="text-lg font-semibold text-foreground">
Rs.&nbsp;{totalCost.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
</p>
</div>
</div>
</div>
</FieldGroup>
{/* Right — scrollable items */}
<div className="flex min-h-0 flex-col rounded-2xl border p-4 lg:col-span-1">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">Line Items</p>
<p className="text-sm text-muted-foreground">Add items to order</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append(defaultItem())}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add
</Button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{fields.map((f, index) => {
const selected = itemsById.get(watchedItems[index]?.itemId ?? '')
return (
<div key={f.id} className="space-y-3 rounded-2xl border p-4">
<FormField
control={form.control}
name={`items.${index}.itemId`}
label="Item"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={val => {
field.onChange(val ?? '')
const item = val ? itemsById.get(val) : undefined
if (item) form.setValue(`items.${index}.unitCost`, item.salePrice, { shouldValidate: true })
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.map(i => (
<SelectItem key={i.id} value={i.id}>{i.name} — {i.sku}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
label="Qty"
required
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.unitCost`}
label="Unit Cost"
required
render={({ field, invalid }) => (
<Input
type="number"
min={0.01}
step="0.01"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
{selected && (
<p className="text-xs text-muted-foreground">UOM: {selected.baseUomName}</p>
)}
{fields.length > 1 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => remove(index)}>
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
Remove
</Button>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create Order'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,12 +1,16 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
import { PurchaseOrderSheet } from './purchase-order-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -27,6 +31,7 @@ export function PurchaseOrdersClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -130,6 +135,10 @@ export function PurchaseOrdersClient() {
Orders placed with suppliers to restock inventory
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
New Order
</Button>
</div>
<DataTable
@@ -142,6 +151,12 @@ export function PurchaseOrdersClient() {
setPageSize(p.pageSize)
}}
/>
<PurchaseOrderSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
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 />
}
@@ -30,6 +30,7 @@ import {
rides as ridesApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api'
@@ -54,9 +55,6 @@ interface SaleSheetProps {
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
function createDefaultItem() {
return {
@@ -112,9 +110,9 @@ export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) {
itemsApi.list({ pageSize: 200 }),
])
.then(([stores, rides, items]) => {
setStoresList(ensureItems(stores))
setRidesList(ensureItems(rides))
setItemsList(ensureItems(items))
setStoresList(getPaginatedItems(stores))
setRidesList(getPaginatedItems(rides))
setItemsList(getPaginatedItems(items))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -23,7 +23,7 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const schema = z.object({
@@ -47,9 +47,6 @@ interface StoreSheetProps {
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
@@ -72,7 +69,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
useEffect(() => {
if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) =>
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
@@ -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>
)
}
@@ -13,6 +13,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'
@@ -29,6 +30,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 +60,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 })
@@ -125,6 +133,11 @@ export function StoresClient() {
label: 'Edit store',
onClick: openEdit,
},
{
action: 'confirm',
label: 'View visit history',
onClick: openHistory,
},
{
action: 'deactivate',
label: 'Deactivate store',
@@ -139,7 +152,7 @@ export function StoresClient() {
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, openEdit, openHistory, toggleActive])
return (
<>
@@ -173,6 +186,12 @@ export function StoresClient() {
store={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<StoreVisitHistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
store={viewingHistory}
/>
</>
)
}
+1
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' },
],
},
{
@@ -51,6 +51,7 @@ const MOCK_PERMISSIONS: Record<Role, string[]> = {
'routes.view',
'rides.view',
'driver_profiles.view',
'sales_visits.checkin',
],
}
+1
View File
@@ -95,3 +95,4 @@ Ordered by **data dependency** — each module can only work once the modules ab
- Pages live in `app/(dashboard)/<module>/page.tsx`.
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
+6 -3
View File
@@ -75,10 +75,13 @@ export const categories = {
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
api.post(`/categories/${categoryId}/sub-categories`, body).then(r => r.data),
api.post(`/categories/${categoryId}/subcategories`, body).then(r => r.data),
updateSubCategory: (categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
api.put(`/categories/${categoryId}/sub-categories/${subId}`, body).then(r => r.data),
updateSubCategory: (_categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
api.put(`/subcategories/${subId}`, body).then(r => r.data),
deleteSubCategory: (subId: string) =>
api.delete(`/subcategories/${subId}`),
}
// ─── Brands ───────────────────────────────────────────────────────────────────
+37
View File
@@ -2,7 +2,14 @@ import { api } from '@/lib/api'
import type {
PaginatedResponse,
RegionResponse,
RegionalTargetResponse,
RegionalBudgetResponse,
RegionAgencyMapResponse,
RegionalTaxRuleResponse,
RegionHierarchyResponse,
RegionDashboardResponse,
RouteResponse,
RouteVisitResponse,
VehicleResponse,
WarehouseResponse,
VehicleType,
@@ -96,6 +103,24 @@ 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),
setBudget: (id: string, period: string, budgetAmount: number) =>
api.put<RegionalBudgetResponse>(`/regions/${id}/budgets/${period}`, { budgetAmount }).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),
hierarchy: (id: string) =>
api.get<RegionHierarchyResponse>(`/regions/${id}/hierarchy`).then(r => r.data),
dashboard: (id: string, period?: string) =>
api.get<RegionDashboardResponse>(`/regions/${id}/dashboard`, { params: { period } }).then(r => r.data),
}
// ─── Routes ───────────────────────────────────────────────────────────────────
@@ -124,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),
}
+62
View File
@@ -81,10 +81,72 @@ export interface RouteResponse {
startPoint: string
endPoint: string
length: number
ownerUserId: string | null
isActive: boolean
createdAt: string
}
export interface RouteVisitResponse {
id: string
routeId: string
storeId: string
userId: string
checkInAt: string
isDeviationFlagged: boolean
checkOutAt: string | null
}
export interface StoreVisitHistoryItem {
visitId: string
visitDate: string
repUserId: string
outcome: string
}
export interface RegionalTargetResponse {
regionId: string
period: string
targetValue: number
}
export interface RegionalBudgetResponse {
regionId: string
period: string
budgetAmount: number
}
export interface RegionAgencyMapResponse {
regionId: string
agencyUserId: string
}
export interface RegionalTaxRuleResponse {
regionId: string
ruleConfig: string
}
export interface RegionHierarchyRouteItem {
routeId: string
routeName: string
storeIds: string[]
}
export interface RegionHierarchyResponse {
regionId: string
regionName: string
routes: RegionHierarchyRouteItem[]
}
export interface RegionDashboardResponse {
regionId: string
period: string
targetValue: number | null
budgetAmount: number | null
orderCount: number
actualSales: number
lastOrderDate: string | null
}
// ─── Vehicles / Warehouses ───────────────────────────────────────────────────
export interface VehicleResponse {
+163
View File
@@ -506,6 +506,84 @@ 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.
| 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 |
| `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:**
```json
// PUT /api/regions/{id}/targets/{period}
{ "targetValue": 500000 }
```
```json
{ "regionId": "guid", "period": "2026-08", "targetValue": 500000 }
```
**Set budget request / response:**
```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}" }
```
**Hierarchy response:**
```json
{
"regionId": "guid",
"regionName": "string",
"routes": [
{ "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] }
]
}
```
**Dashboard response:**
```json
{
"regionId": "guid",
"period": "2026-08",
"targetValue": 500000, // null if no target set for the period
"budgetAmount": 200000, // null if no budget set for the period
"orderCount": 42,
"actualSales": 187500.00,
"lastOrderDate": "datetime | null"
}
```
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".
---
### 6.6 Routes
@@ -518,6 +596,7 @@ Array of:
| `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID |
| `POST` | `/api/routes` | `routes.create` | Create route |
| `PUT` | `/api/routes/{id}` | `routes.update` | Update route |
| `PUT` | `/api/routes/{id}/region` | `routes.update` | Assign/reassign the route to a region |
| `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route |
**Create/Update request:**
@@ -540,11 +619,95 @@ Array of:
"startPoint": "string",
"endPoint": "string",
"length": 12.5,
"ownerUserId": "guid | null",
"isActive": true,
"createdAt": "datetime"
}
```
**Assign to region request:**
```json
// PUT /api/routes/{id}/region
{ "regionId": "guid" }
```
Returns the same `RouteResponse` shape as above. `404` if the route or the target region doesn't
exist (or either is inactive).
#### Route & Sales Visit Management
Bulk store assignment, owner assignment, and GPS check-in/out for a SalesRep's daily visit
schedule. Not gated by the `routes.*` permission codes above — access is checked in
`RouteService` via `IScopeAccessService.HasRouteScopeAsync`, with a `RouteManagerRoleLevel` (20,
i.e. Supervisor and above) floor for the assignment endpoints. All endpoints below return `404`
if the route doesn't exist, is inactive, or the caller lacks route scope — the response body
carries no distinction between "not found" and "forbidden".
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/routes/{id}/stores` | Bulk-assign a set of stores to this route (replaces each store's `routeId`) |
| `PUT` | `/api/routes/{id}/owner` | Assign or reassign the route's owning SalesRep (`OwnerUserId`) |
| `POST` | `/api/routes/{id}/optimize` | Route sequence optimization — **not yet implemented**, always returns `501 Not Implemented` |
| `POST` | `/api/routes/{id}/visits/check-in` | Record a GPS check-in at a store on this route |
| `POST` | `/api/routes/{id}/visits/check-out` | Record check-out for an open visit at a store on this route |
**Assign stores request / response:**
```json
// POST /api/routes/{id}/stores
{ "storeIds": ["guid", "guid", "..."] }
```
Returns the `RouteResponse` shape. All IDs must reference existing stores or the request is
rejected with `404` (no partial assignment).
```json
{ "id": "guid", "regionId": "guid", "routeName": "string", "startPoint": "string", "endPoint": "string", "length": 12.5, "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" }
```
**Assign owner request / response:**
```json
// PUT /api/routes/{id}/owner
{ "salesRepId": "guid" } // must be an active User
```
Returns the same `RouteResponse` shape. `404` if the target user doesn't exist or is inactive.
**Check-in request / response:**
```json
// POST /api/routes/{id}/visits/check-in
{
"storeId": "guid", // must currently be assigned to this route
"latitude": 6.927079,
"longitude": 79.861244,
"timestamp": "datetime | null" // optional, defaults to server time
}
```
```json
{
"id": "guid",
"routeId": "guid",
"storeId": "guid",
"userId": "guid", // caller's user ID — the visiting rep
"checkInAt": "datetime",
"isDeviationFlagged": false, // true if GPS is > 500m (Haversine) from the store's registered lat/long
"checkOutAt": "datetime | null"
}
```
`isDeviationFlagged` is a basic distance check only — it does not yet trigger any alert/notification
(see [docs/09_fraud_controls.md] `sendRouteDeviationAlert`, which is not implemented).
**Check-out request / response:**
```json
// POST /api/routes/{id}/visits/check-out
{
"storeId": "guid",
"timestamp": "datetime | null" // optional, defaults to server time
}
```
Returns the same shape as check-in with `checkOutAt` populated. `404` if there's no open
(checked-in, not yet checked-out) visit for that route+store.
There is no endpoint to list a route's open/in-progress visits — check-out requires the caller to
already know which store they checked into. A store's full visit history is available via
`GET /api/stores/{id}/visit-history` (see [§6.9 Stores](#69-stores)), which reads from this same
visit log.
---
### 6.7 Vehicles