Compare commits

..

2 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna cd5acbab76 fix(dashboard): handle null responses in widget data fetching 2026-08-29 20:36:01 +05:30
Kalana B. Thilakarathna 29f181d5e1 feat(dashboard): implement customizable dashboard with widget support
- Added `react-grid-layout` for drag-and-drop functionality.
- Created `DashboardCanvas` component to manage layout and widget interactions.
- Introduced `WidgetContainer` and `WidgetPicker` components for widget management.
- Implemented various widgets: Active Rides, Today Sales, Pending Purchase Orders, Stock Alerts, Recent Orders, and Loading Sheets.
- Integrated API calls for fetching and saving dashboard preferences.
- Defined default layouts for different user roles.
- Created a card component for consistent widget styling.
2026-08-29 16:51:25 +05:30
71 changed files with 1169 additions and 9125 deletions
@@ -1,5 +1,4 @@
using Distribution.Features.Region;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
@@ -11,14 +10,11 @@ public class RegionServiceTests
{
private readonly Mock<IRegionRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly RegionService _sut;
public RegionServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
_sut = new RegionService(_repo.Object, _uow.Object);
}
private static Region ActiveRegion(string name = "North") =>
@@ -1,343 +0,0 @@
using Distribution.Features.Region;
using Distribution.Features.Route;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
using Xunit;
namespace Distribution.Tests.Services;
public class RouteServiceTests
{
private readonly Mock<IRouteRepository> _repo = new();
private readonly Mock<IRegionRepository> _regionRepo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly RouteService _sut;
private static readonly Guid RepUserId = Guid.NewGuid();
public RouteServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(20);
_currentUser.Setup(u => u.UserId).Returns(RepUserId);
_sut = new RouteService(_repo.Object, _regionRepo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
private static Models.Route ActiveRoute(Guid? ownerId = null) =>
new() { Id = Guid.NewGuid(), RouteName = "R1", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = true, FK_OwnerUserId = ownerId };
private static Models.Route InactiveRoute() =>
new() { Id = Guid.NewGuid(), RouteName = "Old", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = false };
private static Store ActiveStore(Guid routeId, decimal lat = 10m, decimal lon = 20m) =>
new() { Id = Guid.NewGuid(), FK_RouteId = routeId, IsActive = true, Latitude = lat, Longitude = lon };
// AssignStoresToRouteAsync
[Fact]
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteNotFound()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
Assert.Null(result);
_uow.Verify(u => u.BeginAsync(default), Times.Never);
}
[Fact]
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteInactive()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
Assert.Null(result);
}
[Fact]
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerLacksRouteScope()
{
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
Assert.Null(result);
_uow.Verify(u => u.BeginAsync(default), Times.Never);
}
[Fact]
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerBelowRoleFloor()
{
_currentUser.Setup(u => u.RoleLevel).Returns(10);
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
Assert.Null(result);
}
[Fact]
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenAnyStoreIdMissing()
{
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
var storeIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync([ActiveStore(route.Id)]); // only one of two found
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
Assert.Null(result);
_uow.Verify(u => u.BeginAsync(default), Times.Never);
}
[Fact]
public async Task AssignStoresToRouteAsync_UpdatesEachStore_AndCommits()
{
var route = ActiveRoute();
var otherRouteId = Guid.NewGuid();
var stores = new List<Store> { ActiveStore(otherRouteId), ActiveStore(otherRouteId) };
var storeIds = stores.Select(s => s.Id).ToList();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
Assert.NotNull(result);
Assert.All(stores, s => Assert.Equal(route.Id, s.FK_RouteId));
_repo.Verify(r => r.UpdateStore(It.IsAny<Store>()), Times.Exactly(2));
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task AssignStoresToRouteAsync_Rollbacks_WhenUpdateThrows()
{
var route = ActiveRoute();
var stores = new List<Store> { ActiveStore(route.Id) };
var storeIds = stores.Select(s => s.Id).ToList();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
_repo.Setup(r => r.UpdateStore(It.IsAny<Store>())).Throws(new Exception("db error"));
await Assert.ThrowsAsync<Exception>(() => _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds)));
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
// AssignOwnerAsync
[Fact]
public async Task AssignOwnerAsync_ReturnsNull_WhenRouteNotFound()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
var result = await _sut.AssignOwnerAsync(Guid.NewGuid(), new AssignRouteOwnerRequest(Guid.NewGuid()));
Assert.Null(result);
}
[Fact]
public async Task AssignOwnerAsync_ReturnsNull_WhenTargetUserNotActive()
{
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetActiveUserAsync(It.IsAny<Guid>())).ReturnsAsync((User?)null);
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(Guid.NewGuid()));
Assert.Null(result);
_uow.Verify(u => u.BeginAsync(default), Times.Never);
}
[Fact]
public async Task AssignOwnerAsync_SetsOwner_AndCommits()
{
var route = ActiveRoute();
var salesRep = new User { Id = Guid.NewGuid(), IsActive = true };
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetActiveUserAsync(salesRep.Id)).ReturnsAsync(salesRep);
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(salesRep.Id));
Assert.NotNull(result);
Assert.Equal(salesRep.Id, result.OwnerUserId);
Assert.Equal(salesRep.Id, route.FK_OwnerUserId);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task AssignOwnerAsync_AllowsReassignment_ToDifferentSalesRep()
{
var firstOwner = Guid.NewGuid();
var route = ActiveRoute(firstOwner);
var newRep = new User { Id = Guid.NewGuid(), IsActive = true };
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetActiveUserAsync(newRep.Id)).ReturnsAsync(newRep);
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(newRep.Id));
Assert.NotNull(result);
Assert.Equal(newRep.Id, result.OwnerUserId);
Assert.NotEqual(firstOwner, result.OwnerUserId);
}
// RecordCheckInAsync
[Fact]
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerNotAuthenticated()
{
_currentUser.Setup(u => u.UserId).Returns((Guid?)null);
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
Assert.Null(result);
}
[Fact]
public async Task RecordCheckInAsync_ReturnsNull_WhenRouteNotFoundOrInactive()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
Assert.Null(result);
}
[Fact]
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerLacksRouteScope()
{
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
Assert.Null(result);
}
[Fact]
public async Task RecordCheckInAsync_ReturnsNull_WhenStoreNotOnRoute()
{
var route = ActiveRoute();
var store = ActiveStore(Guid.NewGuid()); // belongs to a different route
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 1, 2, null));
Assert.Null(result);
}
[Fact]
public async Task RecordCheckInAsync_NotFlagged_WhenGpsMatchesStoreLocation()
{
var route = ActiveRoute();
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m);
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 12.9716m, 77.5946m, null));
Assert.NotNull(result);
Assert.False(result.IsDeviationFlagged);
_repo.Verify(r => r.AddVisitLogAsync(It.IsAny<RouteVisitLog>()), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task RecordCheckInAsync_Flagged_WhenGpsFarFromStoreLocation()
{
var route = ActiveRoute();
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m); // Bengaluru
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
// Mumbai coordinates - hundreds of km away
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 19.0760m, 72.8777m, null));
Assert.NotNull(result);
Assert.True(result.IsDeviationFlagged);
}
[Fact]
public async Task RecordCheckInAsync_UsesCurrentUserId_AsVisitingRep()
{
var route = ActiveRoute();
var store = ActiveStore(route.Id);
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, store.Latitude, store.Longitude, null));
Assert.NotNull(result);
Assert.Equal(RepUserId, result.UserId);
}
// RecordCheckOutAsync
[Fact]
public async Task RecordCheckOutAsync_ReturnsNull_WhenNoOpenVisit()
{
var route = ActiveRoute();
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, It.IsAny<Guid>())).ReturnsAsync((RouteVisitLog?)null);
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(Guid.NewGuid(), null));
Assert.Null(result);
_uow.Verify(u => u.BeginAsync(default), Times.Never);
}
[Fact]
public async Task RecordCheckOutAsync_SetsCheckOutAt_AndCommits()
{
var route = ActiveRoute();
var storeId = Guid.NewGuid();
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now.AddHours(-1) };
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null));
Assert.NotNull(result);
Assert.NotNull(result.CheckOutAt);
Assert.NotNull(log.CheckOutAt);
_repo.Verify(r => r.UpdateVisitLog(log), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task RecordCheckOutAsync_Rollbacks_WhenUpdateThrows()
{
var route = ActiveRoute();
var storeId = Guid.NewGuid();
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now };
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
_repo.Setup(r => r.UpdateVisitLog(It.IsAny<RouteVisitLog>())).Throws(new Exception("db error"));
await Assert.ThrowsAsync<Exception>(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null)));
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
}
}
@@ -1,85 +0,0 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Route;
using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
using Xunit;
namespace Distribution.Tests.Services;
public class StoreServiceTests
{
private readonly Mock<IStoreRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly Mock<IRouteRepository> _routeRepo = new();
private readonly StoreService _sut;
public StoreServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(50); // global scope, skips per-store scope lookups
var schemeService = new SchemeService(
Mock.Of<ISchemeRepository>(),
Mock.Of<IOrderRepository>(),
Mock.Of<IItemRepository>(),
_repo.Object,
Mock.Of<IStockRepository>(),
_uow.Object);
_sut = new StoreService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object, schemeService, _routeRepo.Object);
}
private static Store ActiveStore() =>
new() { Id = Guid.NewGuid(), IsActive = true };
// GetVisitHistoryAsync
[Fact]
public async Task GetVisitHistoryAsync_ReturnsNull_WhenStoreNotFound()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Store?)null);
var result = await _sut.GetVisitHistoryAsync(Guid.NewGuid());
Assert.Null(result);
}
[Fact]
public async Task GetVisitHistoryAsync_ReturnsEmpty_WhenNoVisitsLogged()
{
var store = ActiveStore();
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([]);
var result = await _sut.GetVisitHistoryAsync(store.Id);
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetVisitHistoryAsync_MapsVisits_MostRecentFirst()
{
var store = ActiveStore();
var repId = Guid.NewGuid();
var older = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-2), CheckOutAt = DateTime.Now.AddDays(-2).AddHours(1) };
var newer = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-1), CheckOutAt = null };
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([older, newer]);
var result = await _sut.GetVisitHistoryAsync(store.Id);
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Equal(newer.Id, result[0].VisitId);
Assert.Equal("In Progress", result[0].Outcome);
Assert.Equal(older.Id, result[1].VisitId);
Assert.Equal("Completed", result[1].Outcome);
Assert.Equal(repId, result[0].RepUserId);
}
}
-27
View File
@@ -56,11 +56,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Claim> Claims { get; set; }
public DbSet<SupplierClaim> SupplierClaims { get; set; }
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
public DbSet<RegionalTarget> RegionalTargets { get; set; }
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -378,28 +373,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
modelBuilder.Entity<RegionalBudget>()
.HasIndex(b => new { b.FK_RegionId, b.Period })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasOne(m => m.AgencyUser)
.WithMany()
.HasForeignKey(m => m.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTaxRule>()
.HasIndex(r => r.FK_RegionId)
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -7,26 +7,4 @@ public interface IRegionRepository
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
Task AddTargetAsync(Models.RegionalTarget target);
void UpdateTarget(Models.RegionalTarget target);
Task<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,46 +37,4 @@ 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,32 +5,3 @@ public record CreateRegionRequest(string Name);
public record UpdateRegionRequest(string Name);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record 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,60 +22,4 @@ 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();
}
+1 -169
View File
@@ -1,20 +1,9 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
{
private const int 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)
@@ -81,161 +70,4 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
}
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));
}
}
@@ -7,14 +7,4 @@ public interface IRouteRepository
Task AddAsync(Models.Route entity);
void Update(Models.Route entity);
void Remove(Models.Route entity);
Task<Models.User?> GetActiveUserAsync(Guid userId);
Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds);
Task<Models.Store?> GetStoreByIdAsync(Guid storeId);
void UpdateStore(Models.Store store);
Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId);
Task AddVisitLogAsync(Models.RouteVisitLog log);
void UpdateVisitLog(Models.RouteVisitLog log);
Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId);
}
@@ -31,49 +31,10 @@ public class RouteController(RouteService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/region")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPost("{id:guid}/stores")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/owner")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/optimize")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
var result = await service.RecordCheckInAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/visits/check-out")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
+1 -13
View File
@@ -4,16 +4,4 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record AssignRouteToRegionRequest(Guid RegionId);
public record AssignStoresToRouteRequest(List<Guid> StoreIds);
public record AssignRouteOwnerRequest(Guid SalesRepId);
public record RecordCheckInRequest(Guid StoreId, decimal Latitude, decimal Longitude, DateTime? Timestamp);
public record RecordCheckOutRequest(Guid StoreId, DateTime? Timestamp);
public record RouteVisitResponse(Guid Id, Guid RouteId, Guid StoreId, Guid UserId, DateTime CheckInAt, bool IsDeviationFlagged, DateTime? CheckOutAt);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
@@ -22,31 +22,4 @@ public class RouteRepository(AppDBContext context) : IRouteRepository
entity.IsActive = false;
context.Routes.Update(entity);
}
public async Task<Models.User?> GetActiveUserAsync(Guid userId) =>
await context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
public async Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds) =>
await context.Stores.Where(s => storeIds.Contains(s.Id)).ToListAsync();
public async Task<Models.Store?> GetStoreByIdAsync(Guid storeId) =>
await context.Stores.FindAsync(storeId);
public void UpdateStore(Models.Store store) =>
context.Stores.Update(store);
public async Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId) =>
await context.RouteVisitLogs
.Where(v => v.FK_RouteId == routeId && v.FK_StoreId == storeId && v.CheckOutAt == null)
.OrderByDescending(v => v.CheckInAt)
.FirstOrDefaultAsync();
public async Task AddVisitLogAsync(Models.RouteVisitLog log) =>
await context.RouteVisitLogs.AddAsync(log);
public void UpdateVisitLog(Models.RouteVisitLog log) =>
context.RouteVisitLogs.Update(log);
public async Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId) =>
await context.RouteVisitLogs.Where(v => v.FK_StoreId == storeId).ToListAsync();
}
+5 -173
View File
@@ -1,50 +1,20 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Route;
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
{
private const int RouteManagerRoleLevel = 20;
private const double DeviationThresholdMeters = 500;
private async Task<bool> CanManageRouteAsync(Guid routeId)
{
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
}
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
{
const double earthRadiusMeters = 6371000;
var dLat = ToRadians((double)(lat2 - lat1));
var dLon = ToRadians((double)(lon2 - lon1));
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(ToRadians((double)lat1)) * Math.Cos(ToRadians((double)lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return earthRadiusMeters * c;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
private static RouteResponse ToResponse(Models.Route route) =>
new(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
private static RouteVisitResponse ToVisitResponse(Models.RouteVisitLog log) =>
new(log.Id, log.FK_RouteId, log.FK_StoreId, log.FK_UserId, log.CheckInAt, log.IsDeviationFlagged, log.CheckOutAt);
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.IsActive, r.CreatedAt))
.ToList();
public async Task<RouteResponse?> GetByIdAsync(Guid id)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
}
public async Task<RouteResponse> CreateAsync(CreateRouteRequest request, CancellationToken ct = default)
@@ -68,7 +38,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
}
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
@@ -91,30 +61,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
var region = await regionRepo.GetByIdAsync(request.RegionId);
if (region is null || !region.IsActive) return null;
route.FK_RegionId = request.RegionId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -134,119 +81,4 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
}
return true;
}
public async Task<RouteResponse?> AssignStoresToRouteAsync(Guid id, AssignStoresToRouteRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await CanManageRouteAsync(id)) return null;
var stores = await repo.GetStoresByIdsAsync(request.StoreIds);
if (stores.Count != request.StoreIds.Distinct().Count()) return null;
await uow.BeginAsync(ct);
try
{
foreach (var store in stores)
{
store.FK_RouteId = id;
repo.UpdateStore(store);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<RouteResponse?> AssignOwnerAsync(Guid id, AssignRouteOwnerRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await CanManageRouteAsync(id)) return null;
var salesRep = await repo.GetActiveUserAsync(request.SalesRepId);
if (salesRep is null) return null;
route.FK_OwnerUserId = request.SalesRepId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<RouteVisitResponse?> RecordCheckInAsync(Guid id, RecordCheckInRequest request, CancellationToken ct = default)
{
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var store = await repo.GetStoreByIdAsync(request.StoreId);
if (store is null || !store.IsActive || store.FK_RouteId != id) return null;
var distance = DistanceMeters(request.Latitude, request.Longitude, store.Latitude, store.Longitude);
var log = new Models.RouteVisitLog
{
FK_RouteId = id,
FK_StoreId = request.StoreId,
FK_UserId = currentUser.UserId.Value,
CheckInAt = request.Timestamp ?? DateTime.Now,
CheckInLatitude = request.Latitude,
CheckInLongitude = request.Longitude,
IsDeviationFlagged = distance > DeviationThresholdMeters
};
await uow.BeginAsync(ct);
try
{
await repo.AddVisitLogAsync(log);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVisitResponse(log);
}
public async Task<RouteVisitResponse?> RecordCheckOutAsync(Guid id, RecordCheckOutRequest request, CancellationToken ct = default)
{
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var log = await repo.GetOpenVisitAsync(id, request.StoreId);
if (log is null) return null;
log.CheckOutAt = request.Timestamp ?? DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.UpdateVisitLog(log);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVisitResponse(log);
}
}
+3 -7
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, Route.IRouteRepository routeRepo)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
@@ -209,12 +209,8 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
var visits = await routeRepo.GetVisitsByStoreIdAsync(id);
return visits
.OrderByDescending(v => v.CheckInAt)
.Select(v => new StoreVisitHistoryItem(v.Id, v.CheckInAt, v.FK_UserId, v.CheckOutAt is null ? "In Progress" : "Completed"))
.ToList();
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
return [];
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
File diff suppressed because it is too large Load Diff
@@ -1,159 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRegionManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RegionAgencyMaps",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionAgencyMaps", x => x.Id);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalBudgets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BudgetAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalBudgets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalBudgets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTargets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetValue = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTargets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTargets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTaxRules",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
table: "RegionAgencyMaps",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
table: "RegionAgencyMaps",
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalBudgets_FK_RegionId_Period",
table: "RegionalBudgets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTargets_FK_RegionId_Period",
table: "RegionalTargets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTaxRules_FK_RegionId",
table: "RegionalTaxRules",
column: "FK_RegionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RegionAgencyMaps");
migrationBuilder.DropTable(
name: "RegionalBudgets");
migrationBuilder.DropTable(
name: "RegionalTargets");
migrationBuilder.DropTable(
name: "RegionalTaxRules");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,108 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRouteVisitManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_OwnerUserId",
table: "Routes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.CreateTable(
name: "RouteVisitLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RouteId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CheckInAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CheckInLatitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CheckInLongitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IsDeviationFlagged = table.Column<bool>(type: "tinyint(1)", nullable: false),
CheckOutAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RouteVisitLogs", x => x.Id);
table.ForeignKey(
name: "FK_RouteVisitLogs_Routes_FK_RouteId",
column: x => x.FK_RouteId,
principalTable: "Routes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RouteVisitLogs_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RouteVisitLogs_Users_FK_UserId",
column: x => x.FK_UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Routes_FK_OwnerUserId",
table: "Routes",
column: "FK_OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_RouteId",
table: "RouteVisitLogs",
column: "FK_RouteId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_StoreId",
table: "RouteVisitLogs",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_UserId",
table: "RouteVisitLogs",
column: "FK_UserId");
migrationBuilder.AddForeignKey(
name: "FK_Routes_Users_FK_OwnerUserId",
table: "Routes",
column: "FK_OwnerUserId",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Routes_Users_FK_OwnerUserId",
table: "Routes");
migrationBuilder.DropTable(
name: "RouteVisitLogs");
migrationBuilder.DropIndex(
name: "IX_Routes_FK_OwnerUserId",
table: "Routes");
migrationBuilder.DropColumn(
name: "FK_OwnerUserId",
table: "Routes");
}
}
}
@@ -851,121 +851,6 @@ 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")
@@ -1115,9 +1000,6 @@ namespace Distribution.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("FK_OwnerUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
@@ -1137,60 +1019,11 @@ 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")
@@ -2352,58 +2185,6 @@ 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")
@@ -2436,48 +2217,15 @@ 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
@@ -1,16 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionAgencyMap : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
}
}
-14
View File
@@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalBudget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal BudgetAmount { get; set; }
}
}
-14
View File
@@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTarget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal TargetValue { get; set; }
}
}
-13
View File
@@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTaxRule : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string RuleConfig { get; set; }
}
}
-4
View File
@@ -12,9 +12,5 @@ namespace Distribution.Models
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public Guid? FK_OwnerUserId { get; set; }
[ForeignKey("FK_OwnerUserId")]
public User? OwnerUser { get; set; }
}
}
-26
View File
@@ -1,26 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RouteVisitLog : Template
{
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
public Route Route { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public DateTime CheckInAt { get; set; }
public decimal CheckInLatitude { get; set; }
public decimal CheckInLongitude { get; set; }
public bool IsDeviationFlagged { get; set; } = false;
public DateTime? CheckOutAt { get; set; }
}
}
-4
View File
@@ -29,10 +29,6 @@ function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-s
via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3)
and route/region-scoped authorization for Stores and Orders.
> **Resolved** — `StoreDTOs.cs`'s `CreateStoreRequest`/`UpdateStoreRequest` now carry `RouteId`,
> and a dedicated bulk-assign endpoint (`POST /api/routes/{id}/stores`) was added in
> [11_route_sales_management.md](./11_route_sales_management.md).
### 3. Role Controller — Missing Authorization
`RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]`
+3 -4
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) | Not implemented. The underlying data now exists — `POST /api/routes/{id}/visits/check-in` ([11_route_sales_management.md](./11_route_sales_management.md)) computes and stores `IsDeviationFlagged` on each `RouteVisitLog` row (Haversine distance vs. the store's registered lat/long) — but nothing reads that flag to raise an alert/anomaly flag yet. |
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Triggered from route check-in/out data (§3 of the function reference; not yet in any numbered step — see [11_route_sales_management.md](./11_route_sales_management.md)) |
| — | `GET /api/audit-log?entityType=...&entityId=...` |
| — | `GET /api/anomaly-flags?status=Open` |
@@ -57,9 +57,8 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr
## Automated Checks & Flags
- Returns without a matching prior sale (`ReturnWithoutSale`).
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — the GPS check-in/out data this
depends on now exists ([11_route_sales_management.md](./11_route_sales_management.md)'s
`RouteVisitLog.IsDeviationFlagged`), but no anomaly flag or alert reads it yet.
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — depends on GPS check-in/out
data from [11_route_sales_management.md](./11_route_sales_management.md).
- Agents with consistently high variance rates across closed rides (`HighVarianceRate`) — reads
`Variance Record` history from [07_ride_reconciliation.md](./07_ride_reconciliation.md).
- Cash collected below invoiced amount (`CashShortfall`) — reads `Payment Record` from
+19 -48
View File
@@ -1,6 +1,6 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
@@ -24,77 +24,48 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
### Regional Target / Budget
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
setting the same period again updates it in place rather than inserting a duplicate.
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
### Agency-Region Map
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
the same convention already used by `Claim.FK_AgencyUserId` (§18).
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
### Regional Tax Rule
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
it's stored for a future pricing-rule integration.
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
---
## API Endpoints
| Function reference | Endpoint | Status |
|---|---|---|
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/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`.
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
regions. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`)
is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching
`UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to
`mapAgencyToRegion`.
- `mapAgencyToRegion` additionally rejects the target user if their role level is below the
`RegionalAgency` threshold (60) — you can only map a user who actually holds (or outranks) that
role.
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
---
## Open Questions
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
queries become a performance concern at scale.
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
---
## Implementation Notes
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
wired into the regions table's row actions.
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
all exist.
> Fill this section in during planning discussion before writing any code.
+3 -34
View File
@@ -1,6 +1,6 @@
# 11 — Route & Sales Visit Management
**Status**: Partially Implemented
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
@@ -62,41 +62,10 @@ access grant which may cover multiple users (e.g. a Supervisor overseeing severa
## Open Questions
- Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it
need actual distance/priority-based computation (external mapping API)? — **Deferred.** The
endpoint is stubbed (`501 Not Implemented`) until this is decided.
need actual distance/priority-based computation (external mapping API)?
---
## Implementation Notes
Implemented: `assignOutletsToRoute`, `assignRouteToSalesRep`/`reassignRoute` (same endpoint),
`recordRouteCheckIn`/`recordRouteCheckOut`, and `getStoreVisitHistory` (§4, now reads from
`RouteVisitLog`). See [BACKEND_API_REFERENCE.md §6.6](../../sharedDocs/BACKEND_API_REFERENCE.md#66-routes)
for full request/response shapes.
Deliberately deferred, not oversights:
- **`optimizeRouteSequence`** — stubbed as `501 Not Implemented`; no distance/priority computation
exists (see Open Questions above).
- **`getRouteAdherence`** / **`getRoutePerformance`** — not built; the underlying metrics
(adherence %, strike rate, missed-visit count) were never defined, so there was nothing concrete
to implement against.
- **`sendRouteDeviationAlert`** — check-in computes and stores a basic `IsDeviationFlagged` boolean
(Haversine distance > 500m from the store's registered `Latitude`/`Longitude`) but does not call
any alert/notification pipeline, since [09_fraud_controls.md](./09_fraud_controls.md) itself is
not implemented yet.
Data model differs slightly from the original sketch above: `RouteId`/`OwnerUserId` became a
nullable `FK_OwnerUserId` directly on `Route` (no separate ownership table needed for a
single-owner field), and `Route Visit Log`'s `CheckInGps` became two decimal columns
(`CheckInLatitude`/`CheckInLongitude`) to match the existing `Store.Latitude`/`Longitude` shape
and make the distance calculation straightforward.
Authorization for the new endpoints follows the `RegionService.CanManageRegionAsync` pattern
(scope check via `IScopeAccessService`, no dedicated `routes.*` permission code): bulk store
assignment and owner assignment require `IScopeAccessService.HasRouteScopeAsync` plus role level
≥ 20 (Supervisor+); check-in/check-out require route scope only, no role floor, since any rep with
access to the route should be able to log their own visits.
Frontend: implemented in `distribution_fe` — an admin "Manage" dialog on the Routes page (owner +
bulk store assignment), a read-only visit-history dialog on the Stores page, and a new rep-facing
`/sales-visits` page for GPS check-in/check-out (see `distribution_fe/app/(dashboard)/sales-visits/`).
> Fill this section in during planning discussion before writing any code.
+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 | Wired to Step 11 — returns a plain array (not paginated) read from `RouteVisitLog` |
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme |
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
+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. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
+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) | Done | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Partially Implemented | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
@@ -116,27 +116,27 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/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. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
### 3. Route Management
| Function | What it does | Status |
|---|---|---|
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. |
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Implemented — `POST /api/routes/{id}/stores` bulk-replaces `FK_RouteId` for the given store ids (all-or-nothing; 404 if any id doesn't exist). |
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Implemented — `PUT /api/routes/{id}/owner`, backed by the new `Route.FK_OwnerUserId` field, distinct from the existing `UserRouteScope` grant. |
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Stubbed — `POST /api/routes/{id}/optimize` always returns `501 Not Implemented`; no distance/priority computation exists yet (see open question in [11_route_sales_management.md](./11_route_sales_management.md)). |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Implemented — `POST /api/routes/{id}/visits/check-in`, backed by the new `RouteVisitLog` entity. Sets `IsDeviationFlagged` via a basic Haversine distance check (>500m) against the store's `Latitude`/`Longitude`; does not yet call `sendRouteDeviationAlert` (§9, unimplemented). |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Implemented — `POST /api/routes/{id}/visits/check-out`, closes the matching open `RouteVisitLog` row (404 if none). |
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — metrics deferred, out of scope for the current pass. |
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Implemented — same endpoint as `assignRouteToSalesRep` (`PUT /api/routes/{id}/owner`); the doc treats these as one action, distinguished only by caller intent. |
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented — metrics deferred, out of scope for the current pass. |
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Not implemented. |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Not implemented. |
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — depends on check-in/check-out above. |
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Not implemented. |
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented. |
| `planDeliveryRoute(warehouseId, orderIds[])` | Builds the warehouse-to-outlet dispatch route (separate from the sales visit route). | Not implemented — this is the `DeliveryRun` concept; see [03_ride_lifecycle.md](./03_ride_lifecycle.md). |
### 4. Store (Outlet) Management
@@ -152,7 +152,7 @@ here — see `SRS_User_Management_Module.pdf`.
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Implemented — `GET /api/stores/{id}/visit-history` now reads from the `RouteVisitLog` table (§3), returning each visit's date, rep, and outcome (`In Progress`/`Completed`). |
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). |
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. |
@@ -1,276 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type {
RegionResponse,
UserResponse,
RegionDashboardResponse,
RegionHierarchyResponse,
} from '@/types/api'
interface RegionManageSheetProps {
open: boolean
onClose: () => void
region: RegionResponse | null
}
function currentPeriod() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) {
const [period, setPeriod] = useState(currentPeriod())
const [targetValue, setTargetValue] = useState('')
const [budgetAmount, setBudgetAmount] = useState('')
const [ruleConfig, setRuleConfig] = useState('')
const [agencyUserId, setAgencyUserId] = useState('')
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
const [dashboard, setDashboard] = useState<RegionDashboardResponse | null>(null)
const [hierarchy, setHierarchy] = useState<RegionHierarchyResponse | null>(null)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
if (!open || !region) return
setPeriod(currentPeriod())
setTargetValue('')
setBudgetAmount('')
setRuleConfig('')
setAgencyUserId('')
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency')))
.catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' }))
regionsApi.hierarchy(region.id)
.then(setHierarchy)
.catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' }))
}, [open, region])
useEffect(() => {
if (!open || !region) return
regionsApi.dashboard(region.id, period)
.then(setDashboard)
.catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' }))
}, [open, region, period])
if (!region) return null
async function handleSetTarget() {
const value = parseFloat(targetValue)
if (Number.isNaN(value)) return
setSaving('target')
try {
await regionsApi.setTarget(region!.id, period, value)
toast.add({ title: 'Target updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetBudget() {
const value = parseFloat(budgetAmount)
if (Number.isNaN(value)) return
setSaving('budget')
try {
await regionsApi.setBudget(region!.id, period, value)
toast.add({ title: 'Budget updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetTaxRule() {
if (!ruleConfig.trim()) return
setSaving('tax-rule')
try {
await regionsApi.setTaxRule(region!.id, ruleConfig)
toast.add({ title: 'Tax rule updated', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleMapAgency() {
if (!agencyUserId) return
setSaving('agency')
try {
await regionsApi.mapAgency(region!.id, agencyUserId)
toast.add({ title: 'Agency mapped to region', type: 'success' })
setAgencyUserId('')
} catch (err) {
toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Manage {region.name}</DialogTitle>
<DialogDescription>
Targets, budgets, tax rules, agency mapping, and dashboard for this region.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="period">Period</Label>
<Input
id="period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-40"
/>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Regional Target</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 500000"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
/>
<Button type="button" onClick={handleSetTarget} disabled={saving === 'target'}>
{saving === 'target' ? 'Saving…' : 'Set Target'}
</Button>
</div>
{dashboard?.targetValue != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.targetValue}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label>Regional Budget</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 200000"
value={budgetAmount}
onChange={(e) => setBudgetAmount(e.target.value)}
/>
<Button type="button" onClick={handleSetBudget} disabled={saving === 'budget'}>
{saving === 'budget' ? 'Saving…' : 'Set Budget'}
</Button>
</div>
{dashboard?.budgetAmount != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.budgetAmount}</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Tax Rule Config (JSON)</Label>
<div className="flex gap-2">
<Input
placeholder='e.g. {"rate": 0.15}'
value={ruleConfig}
onChange={(e) => setRuleConfig(e.target.value)}
/>
<Button type="button" onClick={handleSetTaxRule} disabled={saving === 'tax-rule'}>
{saving === 'tax-rule' ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Map Regional Agency</Label>
<div className="flex gap-2">
<Select value={agencyUserId} onValueChange={(v) => setAgencyUserId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select an agency user">
{agencyUsers.find((u) => u.id === agencyUserId)?.fullName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{agencyUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={handleMapAgency} disabled={saving === 'agency'}>
{saving === 'agency' ? 'Saving…' : 'Map'}
</Button>
</div>
{agencyUsers.length === 0 && (
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Hierarchy</Label>
{hierarchy && hierarchy.routes.length > 0 ? (
<ul className="text-sm space-y-1">
{hierarchy.routes.map((r) => (
<li key={r.routeId}>
{r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">No routes under this region yet.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Dashboard ({period})</Label>
{dashboard ? (
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Orders: {dashboard.orderCount}</div>
<div>Actual Sales: {dashboard.actualSales}</div>
<div>Target: {dashboard.targetValue ?? '—'}</div>
<div>Budget: {dashboard.budgetAmount ?? '—'}</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Loading dashboard…</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -13,7 +13,6 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
import type { RegionResponse } from '@/types/api'
import { RegionSheet } from './region-sheet'
import { RegionManageSheet } from './region-manage-sheet'
export function RegionsClient() {
const [data, setData] = useState<RegionResponse[]>([])
@@ -25,9 +24,6 @@ export function RegionsClient() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RegionResponse | null>(null)
const [manageOpen, setManageOpen] = useState(false)
const [managing, setManaging] = useState<RegionResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
@@ -55,11 +51,6 @@ export function RegionsClient() {
setSheetOpen(true)
}
function openManage(region: RegionResponse) {
setManaging(region)
setManageOpen(true)
}
async function toggleActive(region: RegionResponse) {
try {
await regionsApi.update(region.id, { isActive: !region.isActive })
@@ -112,11 +103,6 @@ export function RegionsClient() {
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Manage targets, budgets & agencies',
onClick: openManage,
},
{
action: 'edit',
label: 'Edit region',
@@ -136,7 +122,7 @@ export function RegionsClient() {
},
],
},
}), [pageSize, openEdit, openManage, toggleActive])
}), [pageSize, openEdit, toggleActive])
return (
<>
@@ -170,12 +156,6 @@ export function RegionsClient() {
region={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<RegionManageSheet
open={manageOpen}
onClose={() => setManageOpen(false)}
region={managing}
/>
</>
)
}
@@ -1,170 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { routes as routesApi, stores as storesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse, UserResponse, StoreResponse } from '@/types/api'
interface RouteManageSheetProps {
open: boolean
onClose: () => void
route: RouteResponse | null
onSuccess: () => void
}
export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManageSheetProps) {
const [salesReps, setSalesReps] = useState<UserResponse[]>([])
const [ownerUserId, setOwnerUserId] = useState('')
const [allStores, setAllStores] = useState<StoreResponse[]>([])
const [checkedStoreIds, setCheckedStoreIds] = useState<Set<string>>(new Set())
const [storeFilter, setStoreFilter] = useState('')
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
if (!open || !route) return
setOwnerUserId(route.ownerUserId ?? '')
setStoreFilter('')
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep')))
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
storesApi.list({ page: 1, pageSize: 200 })
.then((res) => {
const items = getPaginatedItems(res)
setAllStores(items)
setCheckedStoreIds(new Set(items.filter((s) => s.routeId === route.id).map((s) => s.id)))
})
.catch(() => toast.add({ title: 'Failed to load stores', type: 'error' }))
}, [open, route])
if (!route) return null
async function handleAssignOwner() {
if (!ownerUserId) return
setSaving('owner')
try {
await routesApi.assignOwner(route!.id, ownerUserId)
toast.add({ title: 'Route owner assigned', type: 'success' })
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to assign owner', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSaveStores() {
setSaving('stores')
try {
await routesApi.assignStores(route!.id, Array.from(checkedStoreIds))
toast.add({ title: 'Store assignment saved', type: 'success' })
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to assign stores', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
function toggleStore(storeId: string) {
setCheckedStoreIds((prev) => {
const next = new Set(prev)
if (next.has(storeId)) next.delete(storeId)
else next.add(storeId)
return next
})
}
const filteredStores = allStores.filter((s) =>
s.storeName.toLowerCase().includes(storeFilter.toLowerCase())
)
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Manage {route.routeName}</DialogTitle>
<DialogDescription>
Assign a sales rep owner and the stores on this route.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label>Route Owner (Sales Rep)</Label>
<div className="flex gap-2">
<Select value={ownerUserId} onValueChange={(v) => setOwnerUserId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select a sales rep">
{salesReps.find((u) => u.id === ownerUserId)?.fullName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{salesReps.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={handleAssignOwner} disabled={saving === 'owner'}>
{saving === 'owner' ? 'Saving…' : 'Assign'}
</Button>
</div>
{salesReps.length === 0 && (
<p className="text-xs text-muted-foreground">No users with the SalesRep role found.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Stores on this Route</Label>
<Input
placeholder="Filter stores…"
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
/>
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto rounded-md border p-2">
{filteredStores.length === 0 && (
<p className="text-xs text-muted-foreground p-2">No stores match.</p>
)}
{filteredStores.map((s) => (
<label key={s.id} className="flex items-center gap-2 py-1 px-1 text-sm cursor-pointer">
<Checkbox
checked={checkedStoreIds.has(s.id)}
onCheckedChange={() => toggleStore(s.id)}
/>
{s.storeName}
</label>
))}
</div>
<Button type="button" onClick={handleSaveStores} disabled={saving === 'stores'} className="self-start">
{saving === 'stores' ? 'Saving…' : 'Save Assignment'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -9,11 +9,10 @@ import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { routes as routesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse } from '@/types/api'
import { RouteSheet } from './route-sheet'
import { RouteManageSheet } from './route-manage-sheet'
export function RoutesClient() {
const [data, setData] = useState<RouteResponse[]>([])
@@ -21,12 +20,9 @@ export function RoutesClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [userNameById, setUserNameById] = useState<Record<string, string>>({})
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RouteResponse | null>(null)
const [manageOpen, setManageOpen] = useState(false)
const [managing, setManaging] = useState<RouteResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -45,16 +41,6 @@ export function RoutesClient() {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
useEffect(() => {
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => {
const map: Record<string, string> = {}
for (const u of getPaginatedItems(res)) map[u.id] = u.fullName
setUserNameById(map)
})
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
}, [])
function openCreate() {
setEditing(null)
setSheetOpen(true)
@@ -65,11 +51,6 @@ export function RoutesClient() {
setSheetOpen(true)
}
function openManage(route: RouteResponse) {
setManaging(route)
setManageOpen(true)
}
async function toggleActive(route: RouteResponse) {
try {
await routesApi.update(route.id, { isActive: !route.isActive })
@@ -114,17 +95,6 @@ export function RoutesClient() {
fields: [{ key: 'length', transform: (v) => `${v} km` }],
width: 100,
},
{
key: 'owner',
title: 'Owner',
fields: [{ key: 'ownerUserId' }],
minWidth: 140,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.ownerUserId ? userNameById[row.ownerUserId] ?? 'Unknown' : 'Unassigned'}
</span>
),
},
{
key: 'status',
title: 'Status',
@@ -155,11 +125,6 @@ export function RoutesClient() {
label: 'Edit route',
onClick: openEdit,
},
{
action: 'confirm',
label: 'Manage owner & stores',
onClick: openManage,
},
{
action: 'deactivate',
label: 'Deactivate route',
@@ -174,7 +139,7 @@ export function RoutesClient() {
},
],
},
}), [pageSize, userNameById, openEdit, openManage, toggleActive])
}), [pageSize, openEdit, toggleActive])
return (
<>
@@ -208,13 +173,6 @@ export function RoutesClient() {
route={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<RouteManageSheet
open={manageOpen}
onClose={() => setManageOpen(false)}
route={managing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -29,7 +29,6 @@ import {
routes as routesApi,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api'
@@ -50,6 +49,9 @@ interface RideSheetProps {
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
@@ -89,10 +91,10 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
warehousesApi.list({ pageSize: 200 }),
])
.then(([v, d, r, w]) => {
setVehiclesList(getPaginatedItems(v))
setDriversList(getPaginatedItems(d))
setRoutesList(getPaginatedItems(r))
setWarehousesList(getPaginatedItems(w))
setVehiclesList(ensureItems(v))
setDriversList(ensureItems(d))
setRoutesList(ensureItems(r))
setWarehousesList(ensureItems(w))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
+3 -6
View File
@@ -1,8 +1,5 @@
import { DashboardCanvas } from '@/components/dashboard/dashboard-canvas'
export default function DashboardPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">Welcome to the Distribution ERP.</p>
</div>
)
return <DashboardCanvas />
}
@@ -1,15 +1,12 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
import { GrnSheet } from './grn-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -22,7 +19,6 @@ export function GrnClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -105,10 +101,6 @@ export function GrnClient() {
Stock received into a warehouse against a purchase order
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Record Receipt
</Button>
</div>
<DataTable
@@ -121,12 +113,6 @@ export function GrnClient() {
setPageSize(p.pageSize)
}}
/>
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -1,298 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { PurchaseOrderResponse, WarehouseResponse } from '@/types/api'
const grnItemSchema = z.object({
purchaseOrderItemId: z.string().min(1),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
})
const schema = z.object({
purchaseOrderId: z.string().min(1, 'Purchase order is required'),
warehouseId: z.string().min(1, 'Warehouse is required'),
receivedAt: z.string().min(1, 'Received date is required'),
items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
})
type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
purchaseOrderId: '',
warehouseId: '',
receivedAt: todayIso(),
items: [],
},
})
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
// Load reference data when sheet opens
useEffect(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), items: [] })
setSelectedPO(null)
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
])
.then(([pos, ws]) => {
// Only POs that can still receive goods
const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
setWarehousesList(getPaginatedItems(ws))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
form.setValue('purchaseOrderId', poId, { shouldValidate: true })
if (!poId) {
setSelectedPO(null)
replace([])
return
}
setLoadingPO(true)
try {
const po = await purchaseOrdersApi.get(poId)
setSelectedPO(po)
// Pre-fill items: remaining quantity for each non-cancelled line
const lineItems = (po.items ?? [])
.filter(item => !item.isCancelled)
.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
}))
replace(lineItems)
} catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
} finally {
setLoadingPO(false)
}
}
async function onSubmit(values: FormValues) {
try {
await grnApi.create({
purchaseOrderId: values.purchaseOrderId,
warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items: values.items,
})
toast.add({ title: 'Goods receipt recorded', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Record Goods Receipt</DialogTitle>
<DialogDescription>
Record stock received into a warehouse against a purchase order.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
<FieldGroup>
<FormField
control={form.control}
name="purchaseOrderId"
label="Purchase Order"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={v => handlePOChange(v ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Select purchase order..." />
</SelectTrigger>
<SelectContent>
{eligiblePOs.map(po => (
<SelectItem key={po.id} value={po.id}>
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="receivedAt"
label="Received Date"
required
render={({ field, invalid }) => (
<Input
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
</FieldGroup>
{/* Line items — populated from the selected PO */}
{selectedPO && (
<>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Line Items</p>
{loadingPO ? (
<p className="text-sm text-muted-foreground">Loading items…</p>
) : poItems.length === 0 ? (
<p className="text-sm text-muted-foreground">No receivable items on this order.</p>
) : (
<>
{/* Header row */}
<div className="grid grid-cols-[1fr_100px_100px] gap-3 px-1">
<p className="text-xs font-medium text-muted-foreground">Item</p>
<p className="text-xs font-medium text-muted-foreground">Accepted</p>
<p className="text-xs font-medium text-muted-foreground">Rejected</p>
</div>
{fields.map((f, index) => {
const poItem = poItems[index]
return (
<div key={f.id} className="grid grid-cols-[1fr_100px_100px] items-start gap-3 rounded-xl border px-3 py-3">
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-sm font-medium">{poItem?.itemName}</span>
<span className="text-xs text-muted-foreground">
{poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity}
</span>
</div>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
)
})}
</>
)}
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,262 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import {
purchaseOrders as purchaseOrdersApi,
suppliers as suppliersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, SupplierResponse } from '@/types/api'
const poItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
})
const schema = z.object({
supplierId: z.string().min(1, 'Supplier is required'),
items: z.array(poItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface PurchaseOrderSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function defaultItem() {
return { itemId: '', quantity: 1, unitCost: 0 }
}
export function PurchaseOrderSheet({ open, onClose, onSuccess }: PurchaseOrderSheetProps) {
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { supplierId: '', items: [defaultItem()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const watchedItems = form.watch('items')
const itemsById = useMemo(() => new Map(itemsList.map(i => [i.id, i])), [itemsList])
const totalCost = useMemo(
() => watchedItems.reduce((sum, i) => sum + (Number(i.quantity) || 0) * (Number(i.unitCost) || 0), 0),
[watchedItems]
)
useEffect(() => {
if (!open) return
form.reset({ supplierId: '', items: [defaultItem()] })
Promise.all([
suppliersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
])
.then(([s, it]) => {
setSuppliersList(getPaginatedItems(s))
setItemsList(getPaginatedItems(it))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
async function onSubmit(values: FormValues) {
try {
await purchaseOrdersApi.create(values)
toast.add({ title: 'Purchase order created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create purchase order', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Create Purchase Order</DialogTitle>
<DialogDescription>Issue a new purchase order to a supplier.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6">
<div className="grid min-h-0 gap-6 lg:grid-cols-3">
{/* Left — supplier + summary */}
<FieldGroup className="lg:col-span-2">
<FormField
control={form.control}
name="supplierId"
label="Supplier"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{suppliersList.map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="rounded-2xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Order Summary</p>
<p className="text-sm text-muted-foreground">{fields.length} line item{fields.length !== 1 ? 's' : ''}</p>
</div>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total Cost</p>
<p className="text-lg font-semibold text-foreground">
Rs.&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,16 +1,12 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
import { PurchaseOrderSheet } from './purchase-order-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -31,7 +27,6 @@ export function PurchaseOrdersClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -135,10 +130,6 @@ export function PurchaseOrdersClient() {
Orders placed with suppliers to restock inventory
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
New Order
</Button>
</div>
<DataTable
@@ -151,12 +142,6 @@ export function PurchaseOrdersClient() {
setPageSize(p.pageSize)
}}
/>
<PurchaseOrderSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -1,173 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { routes as routesApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse, StoreResponse, RouteVisitResponse } from '@/types/api'
type VisitState = 'idle' | 'checked-in' | 'checked-out'
function getCurrentPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new Error('Geolocation is not supported by this browser'))
return
}
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 10000 })
})
}
export function SalesVisitsClient() {
const [routeList, setRouteList] = useState<RouteResponse[]>([])
const [selectedRouteId, setSelectedRouteId] = useState('')
const [storeList, setStoreList] = useState<StoreResponse[]>([])
const [loadingStores, setLoadingStores] = useState(false)
const [visitState, setVisitState] = useState<Record<string, VisitState>>({})
const [deviationByStore, setDeviationByStore] = useState<Record<string, boolean>>({})
const [busyStoreId, setBusyStoreId] = useState<string | null>(null)
useEffect(() => {
routesApi.list({ page: 1, pageSize: 200 })
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
}, [])
useEffect(() => {
if (!selectedRouteId) {
setStoreList([])
return
}
setLoadingStores(true)
storesApi.list({ routeId: selectedRouteId, page: 1, pageSize: 200 })
.then((res) => {
setStoreList(getPaginatedItems(res).filter((s) => s.isActive))
setVisitState({})
setDeviationByStore({})
})
.catch(() => toast.add({ title: 'Failed to load stores', type: 'error' }))
.finally(() => setLoadingStores(false))
}, [selectedRouteId])
async function handleCheckIn(store: StoreResponse) {
setBusyStoreId(store.id)
try {
const position = await getCurrentPosition()
const result: RouteVisitResponse = await routesApi.checkIn(selectedRouteId, {
storeId: store.id,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
setVisitState((prev) => ({ ...prev, [store.id]: 'checked-in' }))
setDeviationByStore((prev) => ({ ...prev, [store.id]: result.isDeviationFlagged }))
toast.add({
title: 'Checked in',
description: result.isDeviationFlagged ? 'Location does not match the store’s registered address.' : undefined,
type: result.isDeviationFlagged ? 'error' : 'success',
})
} catch (err) {
const message = err instanceof Error && !getApiError(err) ? err.message : getApiError(err)
toast.add({ title: 'Check-in failed', description: message, type: 'error' })
} finally {
setBusyStoreId(null)
}
}
async function handleCheckOut(store: StoreResponse) {
setBusyStoreId(store.id)
try {
await routesApi.checkOut(selectedRouteId, { storeId: store.id })
setVisitState((prev) => ({ ...prev, [store.id]: 'checked-out' }))
toast.add({ title: 'Checked out', type: 'success' })
} catch (err) {
toast.add({ title: 'Check-out failed', description: getApiError(err), type: 'error' })
} finally {
setBusyStoreId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Sales Visits</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Check in and out of scheduled store visits along a route
</p>
</div>
<div className="flex flex-col gap-2 max-w-sm">
<Label>Route</Label>
<Select value={selectedRouteId} onValueChange={(v) => setSelectedRouteId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select a route">
{routeList.find((r) => r.id === selectedRouteId)?.routeName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{routeList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{loadingStores && <p className="text-sm text-muted-foreground">Loading stores…</p>}
{!loadingStores && selectedRouteId && storeList.length === 0 && (
<p className="text-sm text-muted-foreground">No active stores on this route.</p>
)}
<div className="flex flex-col gap-3">
{storeList.map((store) => {
const state = visitState[store.id] ?? 'idle'
const isBusy = busyStoreId === store.id
return (
<div key={store.id} className="flex items-center justify-between rounded-md border p-3">
<div className="flex flex-col">
<span className="text-sm font-medium">{store.storeName}</span>
<span className="text-xs text-muted-foreground">{store.storeAddress}</span>
{deviationByStore[store.id] && state !== 'idle' && (
<Badge variant="destructive" className="w-fit mt-1">Location mismatch</Badge>
)}
</div>
<div className="flex items-center gap-2">
{state === 'checked-out' && <Badge variant="secondary">Completed</Badge>}
{state !== 'checked-out' && (
<Button
type="button"
size="sm"
variant={state === 'checked-in' ? 'outline' : 'default'}
disabled={isBusy || state === 'checked-in'}
onClick={() => handleCheckIn(store)}
>
{isBusy && state === 'idle' ? 'Checking in…' : state === 'checked-in' ? 'Checked In' : 'Check In'}
</Button>
)}
{state === 'checked-in' && (
<Button
type="button"
size="sm"
disabled={isBusy}
onClick={() => handleCheckOut(store)}
>
{isBusy ? 'Checking out…' : 'Check Out'}
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -1,5 +0,0 @@
import { SalesVisitsClient } from './_components/sales-visits-client'
export default function SalesVisitsPage() {
return <SalesVisitsClient />
}
@@ -30,7 +30,6 @@ import {
rides as ridesApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api'
@@ -55,6 +54,9 @@ interface SaleSheetProps {
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
function createDefaultItem() {
return {
@@ -110,9 +112,9 @@ export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) {
itemsApi.list({ pageSize: 200 }),
])
.then(([stores, rides, items]) => {
setStoresList(getPaginatedItems(stores))
setRidesList(getPaginatedItems(rides))
setItemsList(getPaginatedItems(items))
setStoresList(ensureItems(stores))
setRidesList(ensureItems(rides))
setItemsList(ensureItems(items))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -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, getPaginatedItems } from '@/lib/api/index'
import { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const schema = z.object({
@@ -47,6 +47,9 @@ 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
@@ -69,7 +72,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
useEffect(() => {
if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
@@ -1,69 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { stores as storesApi } from '@/lib/api/index'
import type { StoreResponse, StoreVisitHistoryItem } from '@/types/api'
interface StoreVisitHistorySheetProps {
open: boolean
onClose: () => void
store: StoreResponse | null
}
export function StoreVisitHistorySheet({ open, onClose, store }: StoreVisitHistorySheetProps) {
const [visits, setVisits] = useState<StoreVisitHistoryItem[] | null>(null)
useEffect(() => {
if (!open || !store) return
setVisits(null)
storesApi.visitHistory(store.id)
.then(setVisits)
.catch(() => toast.add({ title: 'Failed to load visit history', type: 'error' }))
}, [open, store])
if (!store) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Visit History — {store.storeName}</DialogTitle>
<DialogDescription>
Check-ins and check-outs logged against this store.
</DialogDescription>
</DialogHeader>
{visits === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : visits.length === 0 ? (
<p className="text-sm text-muted-foreground">No visits logged yet.</p>
) : (
<ul className="flex flex-col gap-2">
{visits.map((v) => (
<li key={v.visitId} className="flex items-center justify-between rounded-md border p-2 text-sm">
<span>
{new Date(v.visitDate).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</span>
<Badge variant={v.outcome === 'Completed' ? 'default' : 'secondary'}>
{v.outcome}
</Badge>
</li>
))}
</ul>
)}
</DialogContent>
</Dialog>
)
}
@@ -13,7 +13,6 @@ import { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/i
import type { StoreResponse, KycStatus } from '@/types/api'
import { StoreSheet } from './store-sheet'
import { StoreVisitHistorySheet } from './store-visit-history-sheet'
function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
if (status === 'Verified') return 'default'
@@ -30,8 +29,6 @@ export function StoresClient() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<StoreResponse | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [viewingHistory, setViewingHistory] = useState<StoreResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -60,11 +57,6 @@ export function StoresClient() {
setSheetOpen(true)
}
function openHistory(store: StoreResponse) {
setViewingHistory(store)
setHistoryOpen(true)
}
async function toggleActive(store: StoreResponse) {
try {
await storesApi.update(store.id, { isActive: !store.isActive })
@@ -133,11 +125,6 @@ export function StoresClient() {
label: 'Edit store',
onClick: openEdit,
},
{
action: 'confirm',
label: 'View visit history',
onClick: openHistory,
},
{
action: 'deactivate',
label: 'Deactivate store',
@@ -152,7 +139,7 @@ export function StoresClient() {
},
],
},
}), [pageSize, openEdit, openHistory, toggleActive])
}), [pageSize, openEdit, toggleActive])
return (
<>
@@ -186,12 +173,6 @@ export function StoresClient() {
store={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<StoreVisitHistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
store={viewingHistory}
/>
</>
)
}
@@ -0,0 +1,252 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { GridLayout, useContainerWidth, type LayoutItem } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import { useAuth } from '@/contexts/auth-context'
import { dashboard, type WidgetLayoutItem } from '@/lib/api/dashboard'
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
import { ROLE_DEFAULT_LAYOUTS } from '@/lib/widgets/defaults'
import { getApiError } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import { WidgetContainer } from './widget-container'
import { WidgetPicker } from './widget-picker'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { HugeiconsIcon } from '@hugeicons/react'
import {
PencilEdit01Icon,
Tick02Icon,
Cancel01Icon,
Add01Icon,
Refresh01Icon,
} from '@hugeicons/core-free-icons'
// ─── Layout helpers ───────────────────────────────────────────────────────────
function toGridItems(items: WidgetLayoutItem[], editable: boolean): LayoutItem[] {
return items.map(item => {
const def = WIDGET_REGISTRY[item.widgetType]
return {
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: def?.minW ?? 2,
minH: def?.minH ?? 2,
...(def?.maxW ? { maxW: def.maxW } : {}),
isDraggable: editable,
isResizable: editable,
}
})
}
function applyGridChanges(updated: readonly LayoutItem[], current: WidgetLayoutItem[]): WidgetLayoutItem[] {
const typeMap = Object.fromEntries(current.map(item => [item.i, item.widgetType]))
return updated
.filter(l => typeMap[l.i])
.map(l => ({ i: l.i, widgetType: typeMap[l.i], x: l.x, y: l.y, w: l.w, h: l.h }))
}
// ─── Component ────────────────────────────────────────────────────────────────
export function DashboardCanvas() {
const { user } = useAuth()
const { width, containerRef } = useContainerWidth()
const [savedLayout, setSavedLayout] = useState<WidgetLayoutItem[]>([])
const [editingLayout, setEditingLayout] = useState<WidgetLayoutItem[]>([])
const [isEditing, setIsEditing] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const roleDefault = ROLE_DEFAULT_LAYOUTS[user.role] ?? []
// ─── Load ──────────────────────────────────────────────────────────────────
const loadPreferences = useCallback(async () => {
setIsLoading(true)
try {
const res = await dashboard.getPreferences()
setSavedLayout(res.layout)
} catch {
setSavedLayout(roleDefault)
} finally {
setIsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.role])
useEffect(() => { loadPreferences() }, [loadPreferences])
// ─── Edit mode ─────────────────────────────────────────────────────────────
function startEditing() {
setEditingLayout([...savedLayout])
setIsEditing(true)
}
function cancelEditing() {
setEditingLayout([])
setIsEditing(false)
}
async function saveLayout() {
setIsSaving(true)
try {
await dashboard.savePreferences(editingLayout)
setSavedLayout(editingLayout)
setIsEditing(false)
toast.add({ title: 'Dashboard saved', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
} finally {
setIsSaving(false)
}
}
function resetToDefaults() {
setEditingLayout([...roleDefault])
}
// ─── Grid mutations ────────────────────────────────────────────────────────
function onGridChange(updated: readonly LayoutItem[]) {
if (!isEditing) return
setEditingLayout(prev => applyGridChanges(updated, prev))
}
function removeWidget(id: string) {
setEditingLayout(prev => prev.filter(item => item.i !== id))
}
function addWidget(widgetType: string) {
const def = WIDGET_REGISTRY[widgetType]
if (!def) return
const newItem: WidgetLayoutItem = {
i: `${widgetType}-${Date.now()}`,
widgetType,
x: 0,
y: Infinity,
w: def.defaultW,
h: def.defaultH,
}
setEditingLayout(prev => [...prev, newItem])
}
// ─── Render ────────────────────────────────────────────────────────────────
const activeLayout = isEditing ? editingLayout : savedLayout
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-7 w-28" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-9 w-24" />
</div>
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
)
}
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground">
{isEditing
? 'Drag to reposition · Resize from the corner · Add or remove widgets'
: `Welcome back, ${user.fullName}`}
</p>
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button variant="outline" size="sm" onClick={resetToDefaults} disabled={isSaving}>
<HugeiconsIcon icon={Refresh01Icon} size={14} strokeWidth={2} />
Reset
</Button>
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} disabled={isSaving}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={cancelEditing} disabled={isSaving}>
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
Cancel
</Button>
<Button size="sm" onClick={saveLayout} disabled={isSaving}>
<HugeiconsIcon icon={Tick02Icon} size={14} strokeWidth={2} />
{isSaving ? 'Saving…' : 'Save'}
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={startEditing}>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
Customize
</Button>
)}
</div>
</div>
{/* Grid */}
{activeLayout.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed py-20 text-center">
<p className="text-sm font-medium text-foreground">Your dashboard is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
Click Customize, then Add widget to get started.
</p>
{!isEditing && (
<Button variant="outline" size="sm" className="mt-4" onClick={startEditing}>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
Customize
</Button>
)}
</div>
) : (
<div ref={containerRef}>
<GridLayout
width={width}
layout={toGridItems(activeLayout, isEditing)}
gridConfig={{ cols: 12, rowHeight: 80, margin: [12, 12] as const }}
dragConfig={{ handle: '.drag-handle' }}
resizeConfig={{ handles: ['se'] as const }}
onLayoutChange={onGridChange}
className="relative"
>
{activeLayout.map(item => (
<div key={item.i}>
<WidgetContainer
widgetType={item.widgetType}
editMode={isEditing}
onRemove={() => removeWidget(item.i)}
/>
</div>
))}
</GridLayout>
</div>
)}
{/* Widget Picker */}
<WidgetPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
role={user.role}
currentLayout={editingLayout}
onAdd={addWidget}
/>
</div>
)
}
@@ -0,0 +1,55 @@
'use client'
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { Cancel01Icon, DragDropVerticalIcon } from '@hugeicons/core-free-icons'
import { cn } from '@/lib/utils'
interface WidgetContainerProps {
widgetType: string
editMode: boolean
onRemove: () => void
}
export function WidgetContainer({ widgetType, editMode, onRemove }: WidgetContainerProps) {
const def = WIDGET_REGISTRY[widgetType]
const Component = def?.component
return (
<Card className={cn('flex h-full flex-col overflow-hidden transition-shadow', editMode && 'ring-1 ring-border')}>
<CardHeader className="flex-row items-center justify-between gap-2 pb-2 pt-3">
<div className="flex min-w-0 items-center gap-2">
{editMode && (
<span
className="drag-handle shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-hidden="true"
>
<HugeiconsIcon icon={DragDropVerticalIcon} size={14} strokeWidth={2} />
</span>
)}
<CardTitle className="truncate">{def?.label ?? widgetType}</CardTitle>
</div>
{editMode && (
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
aria-label={`Remove ${def?.label ?? widgetType} widget`}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
</Button>
)}
</CardHeader>
<CardContent className="flex-1 overflow-hidden pt-0">
{Component ? (
<Component />
) : (
<p className="text-sm text-muted-foreground">Unknown widget type: {widgetType}</p>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,76 @@
'use client'
import { getWidgetsForRole, type WidgetDefinition } from '@/lib/widgets/registry'
import type { Role } from '@/contexts/auth-context'
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
interface WidgetPickerProps {
open: boolean
onClose: () => void
role: Role
currentLayout: WidgetLayoutItem[]
onAdd: (widgetType: string) => void
}
export function WidgetPicker({ open, onClose, role, currentLayout, onAdd }: WidgetPickerProps) {
const available = getWidgetsForRole(role)
const activeTypes = new Set(currentLayout.map(l => l.widgetType))
function handleAdd(def: WidgetDefinition) {
onAdd(def.type)
onClose()
}
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-sm">
<SheetHeader className="px-4 pb-3 pt-4">
<SheetTitle>Add Widget</SheetTitle>
<SheetDescription>
Choose a widget to add to your dashboard.
</SheetDescription>
</SheetHeader>
<Separator />
<div className="flex flex-1 flex-col gap-1 overflow-auto p-4">
{available.map(def => {
const alreadyAdded = activeTypes.has(def.type)
return (
<div
key={def.type}
className="flex items-start justify-between gap-3 rounded-xl border p-3"
>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">{def.label}</p>
<p className="text-xs text-muted-foreground">{def.description}</p>
<p className="mt-1 text-xs text-muted-foreground">
Default size: {def.defaultW}&times;{def.defaultH}
</p>
</div>
<Button
size="sm"
variant={alreadyAdded ? 'outline' : 'default'}
className="mt-0.5 shrink-0"
onClick={() => handleAdd(def)}
>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
{alreadyAdded ? 'Add another' : 'Add'}
</Button>
</div>
)
})}
</div>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { rides } from '@/lib/api/index'
import type { RideResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
export function ActiveRidesWidget() {
const [data, setData] = useState<RideResponse[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await rides.list({ status: 'Active', pageSize: 5, page: 1 })
setData(res.items ?? [])
setTotal(res.total ?? 0)
} catch {
// silent — widget shows empty state
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">active ride{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No active rides right now</p>
) : (
data.map(ride => (
<div
key={ride.id}
className="flex items-center justify-between rounded-xl border px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{ride.driverFullName}</span>
<span className="truncate text-xs text-muted-foreground">{ride.routeName} · {ride.vehicleNumberPlate}</span>
</div>
<Badge variant="secondary" className="ml-2 shrink-0">{ride.rideType}</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,82 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { loadingSheets } from '@/lib/api/index'
import type { LoadingSheetResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Pending: 'outline',
DispatchConfirmed: 'secondary',
ReceiptConfirmed: 'default',
DiscrepancyFlagged: 'destructive',
Resolved: 'default',
}
const STATUS_LABEL: Record<string, string> = {
Pending: 'Pending',
DispatchConfirmed: 'Dispatched',
ReceiptConfirmed: 'Received',
DiscrepancyFlagged: 'Discrepancy',
Resolved: 'Resolved',
}
export function LoadingSheetsWidget() {
const [data, setData] = useState<LoadingSheetResponse[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await loadingSheets.list({ status: 'Pending', pageSize: 5, page: 1 })
setData(res.items ?? [])
setTotal(res.total ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">pending sheet{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending loading sheets</p>
) : (
data.map(sheet => (
<div key={sheet.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{sheet.warehouseName}</span>
<span className="text-xs text-muted-foreground">
{sheet.items.length} item{sheet.items.length !== 1 ? 's' : ''}
</span>
</div>
<Badge variant={STATUS_VARIANT[sheet.status] ?? 'outline'} className="ml-2 shrink-0">
{STATUS_LABEL[sheet.status] ?? sheet.status}
</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,65 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { purchaseOrders } from '@/lib/api/index'
import type { PurchaseOrderResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
export function PendingPOsWidget() {
const [data, setData] = useState<PurchaseOrderResponse[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await purchaseOrders.list({ status: 'Issued', pageSize: 5, page: 1 })
setData(res.items ?? [])
setTotal(res.total ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">pending order{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending purchase orders</p>
) : (
data.map(po => (
<div key={po.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{po.supplierName}</span>
<span className="text-xs text-muted-foreground">
{po.items.length} line{po.items.length !== 1 ? 's' : ''}
</span>
</div>
<span className="ml-2 shrink-0 text-sm font-semibold">
Rs.&nbsp;{po.items.reduce((s, i) => s + i.quantity * i.unitCost, 0).toLocaleString('en-LK', { minimumFractionDigits: 0 })}
</span>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { orders } from '@/lib/api/index'
import type { OrderResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Pending: 'outline',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
export function RecentOrdersWidget() {
const [data, setData] = useState<OrderResponse[]>([])
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await orders.list({ pageSize: 6, page: 1 })
setData(res.items ?? [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
return (
<div className="flex h-full flex-col gap-2 overflow-hidden">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No orders yet</p>
) : (
<div className="flex flex-col gap-1.5 overflow-auto">
{data.map(order => (
<div key={order.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-xs text-muted-foreground">
#{order.id.slice(0, 8).toUpperCase()}
</span>
<span className="text-sm font-medium">
Rs.&nbsp;{order.totalAmount.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
</span>
</div>
<Badge variant={STATUS_VARIANT[order.status] ?? 'outline'} className="ml-2 shrink-0">
{order.status}
</Badge>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { stock } from '@/lib/api/index'
import type { StockBalanceResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
const LOW_STOCK_THRESHOLD = 10
export function StockAlertsWidget() {
const [alerts, setAlerts] = useState<StockBalanceResponse[]>([])
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await stock.balances({ pageSize: 100, page: 1 })
const low = (res.items ?? []).filter(b => b.quantity <= LOW_STOCK_THRESHOLD)
setAlerts(low)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
)
}
const outOfStock = alerts.filter(a => a.quantity === 0)
const lowStock = alerts.filter(a => a.quantity > 0)
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{alerts.length}</span>
<span className="text-sm text-muted-foreground">
alert{alerts.length !== 1 ? 's' : ''}
{outOfStock.length > 0 && (
<span className="ml-1 text-destructive">({outOfStock.length} out of stock)</span>
)}
</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">All stock levels are healthy</p>
) : (
alerts.map(b => (
<div key={`${b.itemId}-${b.locationId}`} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{b.itemName}</span>
<span className="text-xs text-muted-foreground capitalize">{b.locationType}</span>
</div>
<Badge
variant={b.quantity === 0 ? 'destructive' : 'outline'}
className="ml-2 shrink-0"
>
{b.quantity === 0 ? 'Out of stock' : `${b.quantity} left`}
</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { sales } from '@/lib/api/index'
import type { SaleResponse } from '@/types/api'
import { Skeleton } from '@/components/ui/skeleton'
export function TodaySalesWidget() {
const [items, setItems] = useState<SaleResponse[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
setLoading(true)
const today = new Date().toISOString().split('T')[0]
try {
const res = await sales.list({ from: today, to: today, pageSize: 50, page: 1 })
setItems(res.items ?? [])
setTotal(res.total ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-32" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
</div>
)
}
const revenue = items.reduce((sum, s) => sum + s.totalAmount, 0)
const completed = items.filter(s => s.status === 'Completed').length
const pending = items.filter(s => s.status === 'Pending').length
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col">
<span className="text-3xl font-bold">
Rs.&nbsp;{revenue.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
<span className="text-sm text-muted-foreground">revenue today</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold">{total}</span>
<span className="text-xs text-muted-foreground">total</span>
</div>
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold text-green-600">{completed}</span>
<span className="text-xs text-muted-foreground">completed</span>
</div>
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold text-amber-500">{pending}</span>
<span className="text-xs text-muted-foreground">pending</span>
</div>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('rounded-2xl border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn('flex flex-col gap-1 px-4 pt-4 pb-2', className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-title"
className={cn('text-sm font-semibold leading-none', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-description"
className={cn('text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-4 pb-4', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-4 pb-4', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
-1
View File
@@ -73,7 +73,6 @@ export const NAV_GROUPS: NavGroup[] = [
icon: Store01Icon,
items: [
{ title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' },
{ title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' },
],
},
{
@@ -51,7 +51,6 @@ const MOCK_PERMISSIONS: Record<Role, string[]> = {
'routes.view',
'rides.view',
'driver_profiles.view',
'sales_visits.checkin',
],
}
+31
View File
@@ -0,0 +1,31 @@
import { api } from '@/lib/api'
export interface WidgetLayoutItem {
i: string
widgetType: string
x: number
y: number
w: number
h: number
}
export interface DashboardPreferencesResponse {
userId: string | null
role: string
isDefault: boolean
layout: WidgetLayoutItem[]
}
export const dashboard = {
getPreferences: () =>
api.get<DashboardPreferencesResponse>('/dashboard/preferences').then(r => r.data),
savePreferences: (layout: WidgetLayoutItem[]) =>
api.put<DashboardPreferencesResponse>('/dashboard/preferences', { layout }).then(r => r.data),
getDefaults: (role: string) =>
api.get<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`).then(r => r.data),
saveDefaults: (role: string, layout: WidgetLayoutItem[]) =>
api.put<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`, { layout }).then(r => r.data),
}
+2
View File
@@ -10,6 +10,8 @@ export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock'
export { sales, orders, payments } from './sales'
export { getPaginatedItems } from './paginated'
export { dashboard } from './dashboard'
export type { WidgetLayoutItem, DashboardPreferencesResponse } from './dashboard'
export type { PageParams } from './master'
export { getApiError } from '@/lib/api'
-37
View File
@@ -2,14 +2,7 @@ import { api } from '@/lib/api'
import type {
PaginatedResponse,
RegionResponse,
RegionalTargetResponse,
RegionalBudgetResponse,
RegionAgencyMapResponse,
RegionalTaxRuleResponse,
RegionHierarchyResponse,
RegionDashboardResponse,
RouteResponse,
RouteVisitResponse,
VehicleResponse,
WarehouseResponse,
VehicleType,
@@ -103,24 +96,6 @@ 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 ───────────────────────────────────────────────────────────────────
@@ -149,18 +124,6 @@ export const routes = {
remove: (id: string) =>
api.delete(`/routes/${id}`),
assignStores: (id: string, storeIds: string[]) =>
api.post<RouteResponse>(`/routes/${id}/stores`, { storeIds }).then(r => r.data),
assignOwner: (id: string, salesRepId: string) =>
api.put<RouteResponse>(`/routes/${id}/owner`, { salesRepId }).then(r => r.data),
checkIn: (id: string, body: { storeId: string; latitude: number; longitude: number }) =>
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-in`, body).then(r => r.data),
checkOut: (id: string, body: { storeId: string }) =>
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-out`, body).then(r => r.data),
}
// ─── Vehicles ─────────────────────────────────────────────────────────────────
-4
View File
@@ -5,7 +5,6 @@ import type {
KycDocumentResponse,
OutstandingBalanceResponse,
StoreDashboardResponse,
StoreVisitHistoryItem,
KycStatus,
} from '@/types/api'
@@ -59,7 +58,4 @@ export const stores = {
updateKycStatus: (id: string, kycStatus: KycStatus) =>
api.patch<StoreResponse>(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data),
visitHistory: (id: string) =>
api.get<StoreVisitHistoryItem[]>(`/stores/${id}/visit-history`).then(r => r.data),
}
+22
View File
@@ -0,0 +1,22 @@
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
export const ROLE_DEFAULT_LAYOUTS: Record<string, WidgetLayoutItem[]> = {
admin: [
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
{ i: 'active-rides-1', widgetType: 'active-rides', x: 4, y: 0, w: 4, h: 4 },
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 8, y: 0, w: 4, h: 4 },
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 0, y: 4, w: 4, h: 4 },
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 4, y: 4, w: 4, h: 4 },
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 0, y: 8, w: 8, h: 4 },
],
warehouse: [
{ i: 'active-rides-1', widgetType: 'active-rides', x: 0, y: 0, w: 6, h: 4 },
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 6, y: 0, w: 6, h: 4 },
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 0, y: 4, w: 6, h: 4 },
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 6, y: 4, w: 6, h: 4 },
],
sales: [
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 4, y: 0, w: 8, h: 4 },
],
}
+97
View File
@@ -0,0 +1,97 @@
import type { ComponentType } from 'react'
import type { Role } from '@/contexts/auth-context'
import { ActiveRidesWidget } from '@/components/dashboard/widgets/active-rides-widget'
import { TodaySalesWidget } from '@/components/dashboard/widgets/today-sales-widget'
import { PendingPOsWidget } from '@/components/dashboard/widgets/pending-pos-widget'
import { StockAlertsWidget } from '@/components/dashboard/widgets/stock-alerts-widget'
import { RecentOrdersWidget } from '@/components/dashboard/widgets/recent-orders-widget'
import { LoadingSheetsWidget } from '@/components/dashboard/widgets/loading-sheets-widget'
export interface WidgetDefinition {
type: string
label: string
description: string
defaultW: number
defaultH: number
minW: number
minH: number
maxW?: number
allowedRoles: Role[] | '*'
component: ComponentType
}
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
'active-rides': {
type: 'active-rides',
label: 'Active Rides',
description: 'Live view of all currently active rides with driver and route info.',
defaultW: 4,
defaultH: 4,
minW: 3,
minH: 3,
allowedRoles: ['admin', 'warehouse'],
component: ActiveRidesWidget,
},
'today-sales': {
type: 'today-sales',
label: "Today's Sales",
description: "Total revenue and sales counts for today.",
defaultW: 4,
defaultH: 4,
minW: 2,
minH: 3,
allowedRoles: ['admin', 'sales'],
component: TodaySalesWidget,
},
'pending-purchase-orders': {
type: 'pending-purchase-orders',
label: 'Pending Purchase Orders',
description: 'Purchase orders that have been issued but not yet fully received.',
defaultW: 4,
defaultH: 4,
minW: 3,
minH: 3,
allowedRoles: ['admin', 'warehouse'],
component: PendingPOsWidget,
},
'stock-alerts': {
type: 'stock-alerts',
label: 'Stock Alerts',
description: 'Items at zero or critically low stock levels across all locations.',
defaultW: 4,
defaultH: 4,
minW: 3,
minH: 3,
allowedRoles: ['admin', 'warehouse'],
component: StockAlertsWidget,
},
'recent-orders': {
type: 'recent-orders',
label: 'Recent Orders',
description: 'Latest customer orders with status and value.',
defaultW: 5,
defaultH: 4,
minW: 4,
minH: 3,
allowedRoles: ['admin', 'sales'],
component: RecentOrdersWidget,
},
'loading-sheets': {
type: 'loading-sheets',
label: 'Pending Loading Sheets',
description: 'Loading sheets awaiting dispatch confirmation.',
defaultW: 4,
defaultH: 4,
minW: 3,
minH: 3,
allowedRoles: ['admin', 'warehouse'],
component: LoadingSheetsWidget,
},
}
export function getWidgetsForRole(role: Role): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY).filter(def =>
def.allowedRoles === '*' || def.allowedRoles.includes(role)
)
}
+59 -3
View File
@@ -19,6 +19,7 @@
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.86.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
@@ -5092,6 +5093,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -6969,7 +6976,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -8087,7 +8093,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -8212,6 +8217,38 @@
"react": "^19.2.4"
}
},
"node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hook-form": {
"version": "7.86.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
@@ -8232,9 +8269,22 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/recast": {
"version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
@@ -8310,6 +8360,12 @@
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
+1
View File
@@ -23,6 +23,7 @@
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.86.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
-62
View File
@@ -81,72 +81,10 @@ export interface RouteResponse {
startPoint: string
endPoint: string
length: number
ownerUserId: string | null
isActive: boolean
createdAt: string
}
export interface RouteVisitResponse {
id: string
routeId: string
storeId: string
userId: string
checkInAt: string
isDeviationFlagged: boolean
checkOutAt: string | null
}
export interface StoreVisitHistoryItem {
visitId: string
visitDate: string
repUserId: string
outcome: string
}
export interface RegionalTargetResponse {
regionId: string
period: string
targetValue: number
}
export interface RegionalBudgetResponse {
regionId: string
period: string
budgetAmount: number
}
export interface RegionAgencyMapResponse {
regionId: string
agencyUserId: string
}
export interface RegionalTaxRuleResponse {
regionId: string
ruleConfig: string
}
export interface 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,84 +506,6 @@ 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
@@ -596,7 +518,6 @@ role — the response body carries no distinction between "not found" and "forbi
| `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:**
@@ -619,95 +540,11 @@ role — the response body carries no distinction between "not found" and "forbi
"startPoint": "string",
"endPoint": "string",
"length": 12.5,
"ownerUserId": "guid | null",
"isActive": true,
"createdAt": "datetime"
}
```
**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