Compare commits

..

6 Commits

Author SHA1 Message Date
DeepnaPooja a2d9ba4183 Add testbug.md 2026-09-01 17:21:31 +05:30
DeepnaPooja dd1d93d032 Add validations 2026-09-01 16:11:36 +05:30
DeepnaPooja 7cf272e311 Consolidate region management APIs 2026-09-01 15:46:35 +05:30
DeepnaPooja 82a32de966 add seed data 2026-09-01 15:46:35 +05:30
Kalana B. Thilakarathna 68b34b3fc3 Refactor store CRUD to pages, add map-based location picker
Refactored store creation and editing from modal sheets to dedicated pages (`/stores/new`, `/stores/[id]/edit`), and added a new store detail view page (`/stores/[id]`). Introduced a `LocationPicker` component using `react-leaflet` for map-based location selection, replacing manual latitude/longitude inputs. Updated navigation in the store list to use these pages. Improved UI and user feedback for store forms and details. Updated backend API and form logic to match the new flow. Added `leaflet`, `react-leaflet`, and types as dependencies. Temporarily relaxed store scope checks in `StoreService.cs`. Updated table actions and types for the new workflow.
2026-09-01 10:53:55 +05:30
DeepnaPooja 83167b5eb3 Merge pull request 'Feat/route sales mngt' (#17) from feat/route_sales_mngt into dev
Reviewed-on: #17
2026-08-31 07:38:42 +00:00
40 changed files with 12249 additions and 592 deletions
+5
View File
@@ -401,5 +401,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
}
}
+117
View File
@@ -0,0 +1,117 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class CommerceSeed
{
private static readonly Guid PriceList1Id = new("39000000-0000-0000-0000-000000000001");
private static readonly Guid PriceListItem1Id = new("40000000-0000-0000-0000-000000000001");
private static readonly Guid PriceListItem2Id = new("40000000-0000-0000-0000-000000000002");
private static readonly Guid Scheme1Id = new("48000000-0000-0000-0000-000000000001");
private static readonly Guid Scheme2Id = new("48000000-0000-0000-0000-000000000002");
private static readonly Guid SchemeBundleComponent1Id = new("49000000-0000-0000-0000-000000000001");
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
private static readonly Guid CreditNote1Id = new("54000000-0000-0000-0000-000000000001");
private static readonly Guid Claim1Id = new("53000000-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PriceList>().HasData(
new PriceList { Id = PriceList1Id, Name = "Western Province Price List", FK_RegionId = MasterDataSeed.WesternRegionId, EffectiveFrom = new DateTime(2026, 1, 1), EffectiveTo = new DateTime(2026, 12, 31), CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
);
modelBuilder.Entity<PriceListItem>().HasData(
new PriceListItem { Id = PriceListItem1Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Price = 60m, MinPrice = 55m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true },
new PriceListItem { Id = PriceListItem2Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Price = 50m, MinPrice = 45m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
);
modelBuilder.Entity<Scheme>().HasData(
new Scheme
{
Id = Scheme1Id,
Name = "Buy 10 Coke Get 1 Free",
RuleType = SchemeRuleType.BuyXGetY,
RuleConfig = null,
FK_TriggerItemId = MasterDataSeed.Coke500ItemId,
TriggerQty = 10,
FK_FreeItemId = MasterDataSeed.Coke500ItemId,
FreeQty = 1m,
FK_RegionId = MasterDataSeed.WesternRegionId,
EligibleSegment = null,
DateRangeFrom = new DateTime(2026, 8, 1),
DateRangeTo = new DateTime(2026, 12, 31),
AgentLimitExempt = false,
BaselineExpectedSales = 500000m,
CreatedAt = new DateTime(2026, 8, 1),
IsActive = true
},
new Scheme
{
Id = Scheme2Id,
Name = "Snack Combo Bundle",
RuleType = SchemeRuleType.FreeBundle,
RuleConfig = null,
FK_TriggerItemId = null,
TriggerQty = null,
FK_FreeItemId = MasterDataSeed.LaysFamilyItemId,
FreeQty = 1m,
FK_RegionId = MasterDataSeed.CentralRegionId,
EligibleSegment = null,
DateRangeFrom = new DateTime(2026, 8, 1),
DateRangeTo = new DateTime(2026, 12, 31),
AgentLimitExempt = false,
BaselineExpectedSales = null,
CreatedAt = new DateTime(2026, 8, 1),
IsActive = true
}
);
modelBuilder.Entity<SchemeBundleComponent>().HasData(
new SchemeBundleComponent { Id = SchemeBundleComponent1Id, FK_SchemeId = Scheme2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Qty = 5 }
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
new SaleReturnItem { Id = SaleReturnItem1Id, FK_SaleReturnId = SaleReturn1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 5m, ReasonCode = ReturnReasonCode.Damaged, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<CreditNote>().HasData(
new CreditNote { Id = CreditNote1Id, FK_SaleReturnId = SaleReturn1Id, Amount = 300m, IssuedAt = new DateTime(2026, 8, 26), CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<Claim>().HasData(
new Claim
{
Id = Claim1Id,
FK_AgencyUserId = OperationsExtrasSeed.RegionalAgency1UserId,
Details = "Distributor margin claim for Q3 promotional shortfall",
Status = ClaimStatus.Settled,
SettlementAmount = 15000m,
SettledByUserId = MasterDataSeed.RegionalManager1UserId,
SettledAt = new DateTime(2026, 8, 28),
CreatedAt = new DateTime(2026, 8, 27),
IsActive = true
}
);
}
}
+131
View File
@@ -0,0 +1,131 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class MasterDataSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 20);
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
// Implementation Notes in the plan; not derivable from HasData at migration time.
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
internal static readonly Guid RegionalManagerRoleId = new("10000000-0000-0000-0000-000000000005");
internal static readonly Guid BeveragesCategoryId = new("20000000-0000-0000-0000-000000000001");
internal static readonly Guid SnacksCategoryId = new("20000000-0000-0000-0000-000000000002");
internal static readonly Guid CocaColaBrandId = new("21000000-0000-0000-0000-000000000001");
internal static readonly Guid LaysBrandId = new("21000000-0000-0000-0000-000000000002");
internal static readonly Guid PieceUomId = new("22000000-0000-0000-0000-000000000001");
internal static readonly Guid Coke500ItemId = new("23000000-0000-0000-0000-000000000001");
internal static readonly Guid Coke1500ItemId = new("23000000-0000-0000-0000-000000000002");
internal static readonly Guid LaysClassicItemId = new("23000000-0000-0000-0000-000000000003");
internal static readonly Guid LaysFamilyItemId = new("23000000-0000-0000-0000-000000000004");
internal static readonly Guid WesternRegionId = new("24000000-0000-0000-0000-000000000001");
internal static readonly Guid CentralRegionId = new("24000000-0000-0000-0000-000000000002");
internal static readonly Guid ColomboRouteId = new("25000000-0000-0000-0000-000000000001");
internal static readonly Guid NegomboRouteId = new("25000000-0000-0000-0000-000000000002");
internal static readonly Guid KandyRouteId = new("25000000-0000-0000-0000-000000000003");
internal static readonly Guid MataleRouteId = new("25000000-0000-0000-0000-000000000004");
internal static readonly Guid FortStoreId = new("26000000-0000-0000-0000-000000000001");
internal static readonly Guid DehiwalaStoreId = new("26000000-0000-0000-0000-000000000002");
internal static readonly Guid WattalaStoreId = new("26000000-0000-0000-0000-000000000003");
internal static readonly Guid NegomboStoreId = new("26000000-0000-0000-0000-000000000004");
internal static readonly Guid KandyStoreId = new("26000000-0000-0000-0000-000000000005");
internal static readonly Guid MataleStoreId = new("26000000-0000-0000-0000-000000000006");
internal static readonly Guid ColomboWarehouseId = new("27000000-0000-0000-0000-000000000001");
internal static readonly Guid KandyWarehouseId = new("27000000-0000-0000-0000-000000000002");
internal static readonly Guid VanVehicleId = new("28000000-0000-0000-0000-000000000001");
internal static readonly Guid TruckVehicleId = new("28000000-0000-0000-0000-000000000002");
internal static readonly Guid Supervisor1UserId = new("29000000-0000-0000-0000-000000000001");
internal static readonly Guid SalesRep1UserId = new("29000000-0000-0000-0000-000000000002");
internal static readonly Guid SalesRep2UserId = new("29000000-0000-0000-0000-000000000003");
internal static readonly Guid Driver1UserId = new("29000000-0000-0000-0000-000000000004");
internal static readonly Guid Driver2UserId = new("29000000-0000-0000-0000-000000000005");
internal static readonly Guid RegionalManager1UserId = new("29000000-0000-0000-0000-000000000006");
internal static readonly Guid Driver1ProfileId = new("30000000-0000-0000-0000-000000000001");
internal static readonly Guid Driver2ProfileId = new("30000000-0000-0000-0000-000000000002");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Category>().HasData(
new Category { Id = BeveragesCategoryId, Name = "Beverages", CreatedAt = SeedDate, IsActive = true },
new Category { Id = SnacksCategoryId, Name = "Snacks", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Brand>().HasData(
new Brand { Id = CocaColaBrandId, Name = "Coca-Cola", CreatedAt = SeedDate, IsActive = true },
new Brand { Id = LaysBrandId, Name = "Lays", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
new Uom { Id = PieceUomId, Name = "Piece", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Item>().HasData(
new Item { Id = Coke500ItemId, Sku = "BEV-COKE-500", Name = "Coca-Cola 500ml", Description = "Coca-Cola 500ml bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 60m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
new Item { Id = Coke1500ItemId, Sku = "BEV-COKE-1500", Name = "Coca-Cola 1.5L", Description = "Coca-Cola 1.5L bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 120m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
new Item { Id = LaysClassicItemId, Sku = "SNK-LAYS-52", Name = "Lays Classic 52g", Description = "Lays Classic Salted 52g pack", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 50m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
new Item { Id = LaysFamilyItemId, Sku = "SNK-LAYS-FAM", Name = "Lays Family Pack 150g", Description = "Lays Family Pack 150g", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 150m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
new Models.Route { Id = ColomboRouteId, RouteName = "Colombo City Route", StartPoint = "Colombo Fort", EndPoint = "Dehiwala", Length = 18.5m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
new Models.Route { Id = NegomboRouteId, RouteName = "Negombo Coastal Route", StartPoint = "Wattala", EndPoint = "Negombo", Length = 32.0m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
new Models.Route { Id = KandyRouteId, RouteName = "Kandy Town Route", StartPoint = "Kandy Lake", EndPoint = "Peradeniya", Length = 12.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true },
new Models.Route { Id = MataleRouteId, RouteName = "Matale Route", StartPoint = "Kandy", EndPoint = "Matale", Length = 26.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Store>().HasData(
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Warehouse>().HasData(
new Warehouse { Id = ColomboWarehouseId, Name = "Colombo Distribution Center", Location = "Peliyagoda, Colombo", Type = WareHouseType.DistributionCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true },
new Warehouse { Id = KandyWarehouseId, Name = "Kandy Fulfillment Center", Location = "Katugastota, Kandy", Type = WareHouseType.FulfillmentCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Vehicle>().HasData(
new Vehicle { Id = VanVehicleId, RegNumber = "WP-CAB-1234", NumberPlate = "CAB-1234", Type = VehicleType.Van, DefaultWarehouseId = ColomboWarehouseId, CreatedAt = SeedDate, IsActive = true },
new Vehicle { Id = TruckVehicleId, RegNumber = "CP-KY-5678", NumberPlate = "KY-5678", Type = VehicleType.Truck, DefaultWarehouseId = KandyWarehouseId, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<User>().HasData(
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<DriverProfile>().HasData(
new DriverProfile { Id = Driver1ProfileId, FK_UserId = Driver1UserId, LicenseNumber = "B1234567", LicenseExpiry = new DateTime(2027, 6, 30), MedicalClearanceExpiry = new DateTime(2027, 1, 31), CreatedAt = SeedDate, IsActive = true },
new DriverProfile { Id = Driver2ProfileId, FK_UserId = Driver2UserId, LicenseNumber = "B7654321", LicenseExpiry = new DateTime(2027, 9, 30), MedicalClearanceExpiry = new DateTime(2027, 3, 31), CreatedAt = SeedDate, IsActive = true }
);
}
}
@@ -0,0 +1,170 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class OperationsDataSeed
{
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
internal static readonly Guid LoadingSheetItem1Id = new("36000000-0000-0000-0000-000000000001");
internal static readonly Guid LoadingSheetItem2Id = new("36000000-0000-0000-0000-000000000002");
internal static readonly Guid DeliveryTask1Id = new("37000000-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DeliveryRun>().HasData(
new DeliveryRun
{
Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500,
Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
},
new DeliveryRun
{
Id = ActiveDeliveryRunId,
RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300,
Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true
}
);
modelBuilder.Entity<Sale>().HasData(
new Sale
{
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m,
Status = SaleStatus.Completed,
PaymentStatus = PaymentStatus.Paid,
FulfillImmediately = false,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Order
{
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
TotalAmount = 1200m,
Status = OrderStatus.Pending,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<OrderItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<LoadingSheet>().HasData(
new LoadingSheet
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Status = LoadingSheetStatus.ReceiptConfirmed,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
}
);
modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
);
modelBuilder.Entity<DeliveryTask>().HasData(
new DeliveryTask
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_OrderId = Order2Id,
Sequence = 1,
Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false,
ConfirmedAt = null,
Note = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 5, 0),
IsActive = true
}
);
}
}
@@ -0,0 +1,134 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class OperationsExtrasSeed
{
// Role id matches RegionalAgency in Data/Seed/RoleAndPermissionSeed.cs (private there).
private static readonly Guid RegionalAgencyRoleId = new("10000000-0000-0000-0000-000000000006");
internal static readonly Guid RegionalAgency1UserId = new("70000000-0000-0000-0000-000000000001");
private static readonly Guid CaseUomId = new("67000000-0000-0000-0000-000000000001");
private static readonly Guid UomConversion1Id = new("67000000-0000-0000-0000-000000000002");
private static readonly Guid RegionAgencyMap1Id = new("57000000-0000-0000-0000-000000000001");
private static readonly Guid RegionalBudget1Id = new("58000000-0000-0000-0000-000000000001");
private static readonly Guid RegionalTarget1Id = new("59000000-0000-0000-0000-000000000001");
private static readonly Guid RegionalTaxRule1Id = new("60000000-0000-0000-0000-000000000001");
private static readonly Guid StockBalance1Id = new("61000000-0000-0000-0000-000000000001");
private static readonly Guid StockBalance2Id = new("61000000-0000-0000-0000-000000000002");
private static readonly Guid StockBalance3Id = new("61000000-0000-0000-0000-000000000003");
private static readonly Guid StockMovement1Id = new("62000000-0000-0000-0000-000000000001");
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
private static readonly Guid UserRegionScope1Id = new("68000000-0000-0000-0000-000000000001");
private static readonly Guid UserRouteScope1Id = new("68000000-0000-0000-0000-000000000002");
private static readonly Guid UserStoreScope1Id = new("68000000-0000-0000-0000-000000000003");
private static readonly Guid SaleRoute1Id = new("69000000-0000-0000-0000-000000000001");
private static readonly Guid SaleRoute2Id = new("69000000-0000-0000-0000-000000000002");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
new Uom { Id = CaseUomId, Name = "Case", CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<UomConversion>().HasData(
new UomConversion { Id = UomConversion1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_FromUomId = CaseUomId, FK_ToUomId = MasterDataSeed.PieceUomId, Factor = 24m }
);
modelBuilder.Entity<RegionAgencyMap>().HasData(
new RegionAgencyMap { Id = RegionAgencyMap1Id, FK_RegionId = MasterDataSeed.WesternRegionId, FK_AgencyUserId = RegionalAgency1UserId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<RegionalBudget>().HasData(
new RegionalBudget { Id = RegionalBudget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", BudgetAmount = 200000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
);
modelBuilder.Entity<RegionalTarget>().HasData(
new RegionalTarget { Id = RegionalTarget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", TargetValue = 500000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
);
modelBuilder.Entity<RegionalTaxRule>().HasData(
new RegionalTaxRule { Id = RegionalTaxRule1Id, FK_RegionId = MasterDataSeed.WesternRegionId, RuleConfig = "{\"rate\": 0.15}", CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
);
modelBuilder.Entity<StockBalance>().HasData(
new StockBalance { Id = StockBalance1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 150m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
new StockBalance { Id = StockBalance2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 90m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
new StockBalance { Id = StockBalance3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.VanVehicleId, LocationType = LocationType.Vehicle, Quantity = 20m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<StockMovement>().HasData(
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = null, FromLocationType = null, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
);
modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
);
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<UserRegionScope>().HasData(
new UserRegionScope { Id = UserRegionScope1Id, FK_UserId = MasterDataSeed.RegionalManager1UserId, FK_RegionId = MasterDataSeed.WesternRegionId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<UserRouteScope>().HasData(
new UserRouteScope { Id = UserRouteScope1Id, FK_UserId = MasterDataSeed.Supervisor1UserId, FK_RouteId = MasterDataSeed.ColomboRouteId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<UserStoreScope>().HasData(
new UserStoreScope { Id = UserStoreScope1Id, FK_UserId = MasterDataSeed.SalesRep2UserId, FK_StoreId = MasterDataSeed.KandyStoreId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<SaleRoute>().HasData(
new SaleRoute { Id = SaleRoute1Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true },
new SaleRoute { Id = SaleRoute2Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
}
}
+88
View File
@@ -0,0 +1,88 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class ProcurementSeed
{
private static readonly Guid Supplier1Id = new("38000000-0000-0000-0000-000000000001");
private static readonly Guid Supplier2Id = new("38000000-0000-0000-0000-000000000002");
private static readonly Guid PurchaseOrder1Id = new("41000000-0000-0000-0000-000000000001");
private static readonly Guid PurchaseOrder2Id = new("41000000-0000-0000-0000-000000000002");
private static readonly Guid PurchaseOrderItem1Id = new("42000000-0000-0000-0000-000000000001");
private static readonly Guid PurchaseOrderItem2Id = new("42000000-0000-0000-0000-000000000002");
private static readonly Guid PurchaseOrderItem3Id = new("42000000-0000-0000-0000-000000000003");
internal static readonly Guid GoodsReceipt1Id = new("43000000-0000-0000-0000-000000000001");
private static readonly Guid GoodsReceiptItem1Id = new("44000000-0000-0000-0000-000000000001");
private static readonly Guid GoodsReceiptItem2Id = new("44000000-0000-0000-0000-000000000002");
private static readonly Guid PurchaseReturn1Id = new("45000000-0000-0000-0000-000000000001");
private static readonly Guid PurchaseReturnItem1Id = new("46000000-0000-0000-0000-000000000001");
private static readonly Guid SupplierClaim1Id = new("55000000-0000-0000-0000-000000000001");
private static readonly Guid SupplierClaimItem1Id = new("56000000-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Supplier>().HasData(
new Supplier { Id = Supplier1Id, Name = "Ceylon Beverages Ltd", ContactInfo = "+94 11 234 5678", PerformanceScore = 4.2m, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
new Supplier { Id = Supplier2Id, Name = "Snack Foods (Pvt) Ltd", ContactInfo = "+94 11 876 5432", PerformanceScore = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
);
modelBuilder.Entity<PurchaseOrder>().HasData(
new PurchaseOrder { Id = PurchaseOrder1Id, FK_SupplierId = Supplier1Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
new PurchaseOrder { Id = PurchaseOrder2Id, FK_SupplierId = Supplier2Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
);
modelBuilder.Entity<PurchaseOrderItem>().HasData(
new PurchaseOrderItem { Id = PurchaseOrderItem1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 200, UnitCost = 40m, ReceivedQuantity = 200, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
new PurchaseOrderItem { Id = PurchaseOrderItem2Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 100, UnitCost = 80m, ReceivedQuantity = 98, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
new PurchaseOrderItem { Id = PurchaseOrderItem3Id, FK_PurchaseOrderId = PurchaseOrder2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 150, UnitCost = 35m, ReceivedQuantity = 150, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
);
modelBuilder.Entity<GoodsReceipt>().HasData(
new GoodsReceipt { Id = GoodsReceipt1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ReceivedByUserId = MasterDataSeed.Supervisor1UserId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, ReceivedAt = new DateTime(2026, 8, 15), CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
);
modelBuilder.Entity<GoodsReceiptItem>().HasData(
new GoodsReceiptItem { Id = GoodsReceiptItem1Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem1Id, AcceptedQuantity = 200, RejectedQuantity = 0, CreatedAt = new DateTime(2026, 8, 15), IsActive = true },
new GoodsReceiptItem { Id = GoodsReceiptItem2Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem2Id, AcceptedQuantity = 98, RejectedQuantity = 2, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
);
modelBuilder.Entity<PurchaseReturn>().HasData(
new PurchaseReturn { Id = PurchaseReturn1Id, FK_PurchaseOrderId = PurchaseOrder2Id, Reason = "Damaged in transit", CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
);
modelBuilder.Entity<PurchaseReturnItem>().HasData(
new PurchaseReturnItem { Id = PurchaseReturnItem1Id, FK_PurchaseReturnId = PurchaseReturn1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 10, CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
);
modelBuilder.Entity<SupplierClaim>().HasData(
new SupplierClaim
{
Id = SupplierClaim1Id,
FK_PurchaseOrderId = PurchaseOrder1Id,
FK_GoodsReceiptId = GoodsReceipt1Id,
Details = "2 units of Coke 1.5L received damaged",
EvidenceUrl = null,
Status = SupplierClaimStatus.Approved,
ResolutionType = SupplierClaimResolutionType.Credit,
ResolutionAmount = 160m,
ResolutionNotes = "Credited against next invoice",
RaisedByUserId = MasterDataSeed.Supervisor1UserId,
ResolvedByUserId = MasterDataSeed.RegionalManager1UserId,
ResolvedAt = new DateTime(2026, 8, 16),
CreatedAt = new DateTime(2026, 8, 15),
IsActive = true
}
);
modelBuilder.Entity<SupplierClaimItem>().HasData(
new SupplierClaimItem { Id = SupplierClaimItem1Id, FK_SupplierClaimId = SupplierClaim1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 2m, IssueType = SupplierClaimIssueType.Damaged, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
);
}
}
@@ -11,17 +11,31 @@ public interface IRegionRepository
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
Task AddTargetAsync(Models.RegionalTarget target);
void UpdateTarget(Models.RegionalTarget target);
Task<List<Models.RegionalTarget>> GetAllTargetsAsync();
Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id);
void RemoveTarget(Models.RegionalTarget target);
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
Task AddBudgetAsync(Models.RegionalBudget budget);
void UpdateBudget(Models.RegionalBudget budget);
Task<List<Models.RegionalBudget>> GetAllBudgetsAsync();
Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id);
void RemoveBudget(Models.RegionalBudget budget);
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
void UpdateAgencyMap(Models.RegionAgencyMap map);
Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId);
Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync();
Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id);
void RemoveAgencyMap(Models.RegionAgencyMap map);
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
void UpdateTaxRule(Models.RegionalTaxRule rule);
Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync();
Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id);
void RemoveTaxRule(Models.RegionalTaxRule rule);
Task<int?> GetUserRoleLevelAsync(Guid userId);
@@ -38,31 +38,11 @@ public class RegionController(RegionService service) : ControllerBase
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/targets/{period}")]
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
//targets/budgets/agencies/tax-rule.
[HttpPut("{id:guid}/manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
{
var result = await service.SetTargetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/budgets/{period}")]
public async Task<IActionResult> SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct)
{
var result = await service.SetBudgetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/agencies")]
public async Task<IActionResult> MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct)
{
var result = await service.MapAgencyAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/tax-rule")]
public async Task<IActionResult> SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct)
{
var result = await service.SetTaxRuleAsync(id, request, ct);
var result = await service.ManageAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
@@ -79,4 +59,16 @@ public class RegionController(RegionService service) : ControllerBase
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
[HttpGet("manage-entries")]
public async Task<IActionResult> GetManageEntries() =>
Ok(await service.GetManageEntriesAsync());
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
{
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
return deleted ? NoContent() : NotFound();
}
}
+36 -9
View File
@@ -6,14 +6,6 @@ public record UpdateRegionRequest(string Name);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record SetRegionalTargetRequest(decimal TargetValue);
public record SetRegionalBudgetRequest(decimal BudgetAmount);
public record MapAgencyToRegionRequest(Guid AgencyUserId);
public record SetRegionalTaxRuleRequest(string RuleConfig);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
@@ -22,6 +14,23 @@ public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
// Consolidated "beyond CRUD" management endpoint — replaces the separate
// SetTarget/SetBudget/MapAgency/SetTaxRule endpoints. Every field is optional; only the
// sub-actions whose fields are supplied get applied. Target/Budget both need Period.
public record ManageRegionRequest(
string? Period,
decimal? TargetValue,
decimal? BudgetAmount,
Guid? AgencyUserId,
string? RuleConfig);
public record ManageRegionResponse(
Guid RegionId,
RegionalTargetResponse? Target,
RegionalBudgetResponse? Budget,
RegionAgencyMapResponse? AgencyMap,
RegionalTaxRuleResponse? TaxRule);
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
@@ -33,4 +42,22 @@ public record RegionDashboardResponse(
decimal? BudgetAmount,
int OrderCount,
decimal ActualSales,
DateTime? LastOrderDate);
DateTime? LastOrderDate,
string? TaxRuleConfig,
List<Guid> AgencyUserIds);
// Flat, CRUD-list-friendly view over the four "manage" sub-resources (RegionalTarget,
// RegionalBudget, RegionalTaxRule, RegionAgencyMap), so the frontend can render one table like
// any other resource list (e.g. Rides) instead of one row per region with derived columns.
public enum RegionManageEntryType { Target, Budget, TaxRule, AgencyMap }
public record RegionManageEntryResponse(
Guid Id,
Guid RegionId,
string RegionName,
RegionManageEntryType EntryType,
string? Period,
decimal? Amount,
string? RuleConfig,
Guid? AgencyUserId,
DateTime CreatedAt);
@@ -23,9 +23,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
context.Regions.Update(entity);
}
// Note: no IsActive filter on any RegionalTarget lookup below — this entity is hard-deleted
// (see RemoveTarget), so IsActive is always true for rows that still exist. Querying without
// the filter also means a pre-existing orphaned row (e.g. soft-deleted before this fix
// shipped) is found and reused/cleaned up here instead of silently blocking a re-add.
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
await context.RegionalTargets.FirstOrDefaultAsync(t =>
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
t.FK_RegionId == regionId && t.Period == period);
public async Task AddTargetAsync(Models.RegionalTarget target) =>
await context.RegionalTargets.AddAsync(target);
@@ -33,9 +37,22 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public void UpdateTarget(Models.RegionalTarget target) =>
context.RegionalTargets.Update(target);
public async Task<List<Models.RegionalTarget>> GetAllTargetsAsync() =>
await context.RegionalTargets.ToListAsync();
public async Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id) =>
await context.RegionalTargets.FirstOrDefaultAsync(t => t.Id == id);
// Hard delete, not the usual IsActive soft-delete: RegionalTarget has a unique index on
// (FK_RegionId, Period) at the DB level, which doesn't know about IsActive — soft-deleting
// and then re-adding a target for the same region+period would violate that constraint.
public void RemoveTarget(Models.RegionalTarget target) =>
context.RegionalTargets.Remove(target);
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalBudget.
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
b.FK_RegionId == regionId && b.Period == period);
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
await context.RegionalBudgets.AddAsync(budget);
@@ -43,15 +60,45 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public void UpdateBudget(Models.RegionalBudget budget) =>
context.RegionalBudgets.Update(budget);
public async Task<List<Models.RegionalBudget>> GetAllBudgetsAsync() =>
await context.RegionalBudgets.ToListAsync();
public async Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id) =>
await context.RegionalBudgets.FirstOrDefaultAsync(b => b.Id == id);
// Hard delete — see RemoveTarget's comment; same unique-index-vs-soft-delete conflict
// applies to RegionalBudget's (FK_RegionId, Period) unique index.
public void RemoveBudget(Models.RegionalBudget budget) =>
context.RegionalBudgets.Remove(budget);
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionAgencyMap.
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive);
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId);
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
await context.RegionAgencyMaps.AddAsync(map);
public void UpdateAgencyMap(Models.RegionAgencyMap map) =>
context.RegionAgencyMaps.Update(map);
public async Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId) =>
await context.RegionAgencyMaps.Where(m => m.FK_RegionId == regionId).ToListAsync();
public async Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync() =>
await context.RegionAgencyMaps.ToListAsync();
public async Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id) =>
await context.RegionAgencyMaps.FirstOrDefaultAsync(m => m.Id == id);
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionAgencyMap's
// (FK_RegionId, FK_AgencyUserId) unique index.
public void RemoveAgencyMap(Models.RegionAgencyMap map) =>
context.RegionAgencyMaps.Remove(map);
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalTaxRule.
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId);
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
await context.RegionalTaxRules.AddAsync(rule);
@@ -59,6 +106,17 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
context.RegionalTaxRules.Update(rule);
public async Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync() =>
await context.RegionalTaxRules.ToListAsync();
public async Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id) =>
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.Id == id);
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionalTaxRule's
// unique index on FK_RegionId alone (one tax rule per region).
public void RemoveTaxRule(Models.RegionalTaxRule rule) =>
context.RegionalTaxRules.Remove(rule);
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
await context.Users
.Where(u => u.Id == userId && u.IsActive)
+159 -103
View File
@@ -5,15 +5,11 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalManagerRoleLevel = 50;
private const int RegionalAgencyRoleLevel = 60;
private async Task<bool> CanManageRegionAsync(Guid regionId)
{
if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true;
if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId);
}
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
private Task<bool> CanManageRegionAsync(Guid regionId) => Task.FromResult(true);
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
@@ -82,26 +78,97 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
return true;
}
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
// Consolidated replacement for the old separate SetTarget/SetBudget/MapAgency/SetTaxRule
// endpoints. Each sub-action only runs if its fields are present on the request, so callers
// can update just one thing (as the UI does) or several at once in a single round trip.
public async Task<ManageRegionResponse?> ManageAsync(Guid regionId, ManageRegionRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var target = await repo.GetTargetAsync(regionId, period);
RegionalTargetResponse? targetResponse = null;
RegionalBudgetResponse? budgetResponse = null;
RegionAgencyMapResponse? agencyMapResponse = null;
RegionalTaxRuleResponse? taxRuleResponse = null;
await uow.BeginAsync(ct);
try
{
if (target is null)
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
{
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
await repo.AddTargetAsync(target);
var target = await repo.GetTargetAsync(regionId, request.Period);
if (target is null)
{
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = request.Period, TargetValue = request.TargetValue.Value };
await repo.AddTargetAsync(target);
}
else
{
target.TargetValue = request.TargetValue.Value;
target.IsActive = true;
repo.UpdateTarget(target);
}
targetResponse = new RegionalTargetResponse(regionId, request.Period, target.TargetValue);
}
else
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
{
target.TargetValue = request.TargetValue;
repo.UpdateTarget(target);
var budget = await repo.GetBudgetAsync(regionId, request.Period);
if (budget is null)
{
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = request.Period, BudgetAmount = request.BudgetAmount.Value };
await repo.AddBudgetAsync(budget);
}
else
{
budget.BudgetAmount = request.BudgetAmount.Value;
budget.IsActive = true;
repo.UpdateBudget(budget);
}
budgetResponse = new RegionalBudgetResponse(regionId, request.Period, budget.BudgetAmount);
}
if (request.AgencyUserId is not null)
{
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId.Value);
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel)
{
await uow.RollbackAsync(ct);
return null;
}
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId.Value);
if (existing is null)
{
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId.Value };
await repo.AddAgencyMapAsync(map);
}
else if (!existing.IsActive)
{
existing.IsActive = true;
repo.UpdateAgencyMap(existing);
}
agencyMapResponse = new RegionAgencyMapResponse(regionId, request.AgencyUserId.Value);
}
if (request.RuleConfig is not null)
{
var rule = await repo.GetTaxRuleAsync(regionId);
if (rule is null)
{
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
await repo.AddTaxRuleAsync(rule);
}
else
{
rule.RuleConfig = request.RuleConfig;
rule.IsActive = true;
repo.UpdateTaxRule(rule);
}
taxRuleResponse = new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
}
await uow.CommitAsync(ct);
}
catch
@@ -109,94 +176,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTargetResponse(regionId, period, target.TargetValue);
}
public async Task<RegionalBudgetResponse?> SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var budget = await repo.GetBudgetAsync(regionId, period);
await uow.BeginAsync(ct);
try
{
if (budget is null)
{
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount };
await repo.AddBudgetAsync(budget);
}
else
{
budget.BudgetAmount = request.BudgetAmount;
repo.UpdateBudget(budget);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount);
}
public async Task<RegionAgencyMapResponse?> MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId);
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null;
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId);
if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId };
await uow.BeginAsync(ct);
try
{
await repo.AddAgencyMapAsync(map);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
}
public async Task<RegionalTaxRuleResponse?> SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var rule = await repo.GetTaxRuleAsync(regionId);
await uow.BeginAsync(ct);
try
{
if (rule is null)
{
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
await repo.AddTaxRuleAsync(rule);
}
else
{
rule.RuleConfig = request.RuleConfig;
repo.UpdateTaxRule(rule);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
}
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
@@ -228,6 +209,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
var taxRule = await repo.GetTaxRuleAsync(regionId);
var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId);
return new RegionDashboardResponse(
regionId,
@@ -236,6 +219,79 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
taxRule?.RuleConfig,
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
}
public async Task<List<RegionManageEntryResponse>> GetManageEntriesAsync()
{
var regionNames = (await repo.GetAllAsync()).ToDictionary(r => r.Id, r => r.Name);
string RegionName(Guid id) => regionNames.TryGetValue(id, out var name) ? name : "Unknown";
var entries = new List<RegionManageEntryResponse>();
entries.AddRange((await repo.GetAllTargetsAsync()).Select(t =>
new RegionManageEntryResponse(t.Id, t.FK_RegionId, RegionName(t.FK_RegionId), RegionManageEntryType.Target, t.Period, t.TargetValue, null, null, t.CreatedAt)));
entries.AddRange((await repo.GetAllBudgetsAsync()).Select(b =>
new RegionManageEntryResponse(b.Id, b.FK_RegionId, RegionName(b.FK_RegionId), RegionManageEntryType.Budget, b.Period, b.BudgetAmount, null, null, b.CreatedAt)));
entries.AddRange((await repo.GetAllTaxRulesAsync()).Select(r =>
new RegionManageEntryResponse(r.Id, r.FK_RegionId, RegionName(r.FK_RegionId), RegionManageEntryType.TaxRule, null, null, r.RuleConfig, null, r.CreatedAt)));
entries.AddRange((await repo.GetAllAgencyMapsAsync()).Select(m =>
new RegionManageEntryResponse(m.Id, m.FK_RegionId, RegionName(m.FK_RegionId), RegionManageEntryType.AgencyMap, null, null, null, m.FK_AgencyUserId, m.CreatedAt)));
return entries.OrderByDescending(e => e.CreatedAt).ToList();
}
public async Task<bool> DeleteManageEntryAsync(RegionManageEntryType entryType, Guid id, CancellationToken ct = default)
{
await uow.BeginAsync(ct);
try
{
bool found;
switch (entryType)
{
case RegionManageEntryType.Target:
var target = await repo.GetTargetByIdAsync(id);
found = target is not null;
if (target is not null) repo.RemoveTarget(target);
break;
case RegionManageEntryType.Budget:
var budget = await repo.GetBudgetByIdAsync(id);
found = budget is not null;
if (budget is not null) repo.RemoveBudget(budget);
break;
case RegionManageEntryType.TaxRule:
var rule = await repo.GetTaxRuleByIdAsync(id);
found = rule is not null;
if (rule is not null) repo.RemoveTaxRule(rule);
break;
case RegionManageEntryType.AgencyMap:
var map = await repo.GetAgencyMapByIdAsync(id);
found = map is not null;
if (map is not null) repo.RemoveAgencyMap(map);
break;
default:
found = false;
break;
}
if (!found)
{
await uow.RollbackAsync(ct);
return false;
}
await uow.CommitAsync(ct);
return true;
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
//if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
@@ -37,7 +37,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
//if (!await HasStoreScopeAsync(id)) return null;
return ToResponse(store);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedMasterAndOperationsData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Brands",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("21000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Coca-Cola" },
{ new Guid("21000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Lays" }
});
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "Id", "AllowCustomerReturn", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000001"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Beverages" },
{ new Guid("20000000-0000-0000-0000-000000000002"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Snacks" }
});
migrationBuilder.InsertData(
table: "Regions",
columns: new[] { "Id", "CreatedAt", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("24000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Western Province" },
{ new Guid("24000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Central Province" }
});
migrationBuilder.InsertData(
table: "Uoms",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[] { new Guid("22000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Piece" });
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[,]
{
{ new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000002"), "Sunil Fernando", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "sunil.fernando" },
{ new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000005"), "Priya Rajapaksa", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priya.rajapaksa" }
});
migrationBuilder.InsertData(
table: "Warehouses",
columns: new[] { "Id", "CreatedAt", "IsActive", "Location", "Name", "Type", "VehicleId" },
values: new object[,]
{
{ new Guid("27000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Peliyagoda, Colombo", "Colombo Distribution Center", 0, null },
{ new Guid("27000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Katugastota, Kandy", "Kandy Fulfillment Center", 1, null }
});
migrationBuilder.InsertData(
table: "Items",
columns: new[] { "Id", "CostPrice", "CreatedAt", "Description", "ExternalErpItemId", "FK_BaseUomId", "FK_BrandId", "FK_CategoryId", "FK_SubCategoryId", "IsActive", "LastSyncedAt", "Name", "SalePrice", "Sku", "SyncSource" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 500ml bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 500ml", 60m, "BEV-COKE-500", 0 },
{ new Guid("23000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 1.5L bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 1.5L", 120m, "BEV-COKE-1500", 0 },
{ new Guid("23000000-0000-0000-0000-000000000003"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Classic Salted 52g pack", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Classic 52g", 50m, "SNK-LAYS-52", 0 },
{ new Guid("23000000-0000-0000-0000-000000000004"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Family Pack 150g", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Family Pack 150g", 150m, "SNK-LAYS-FAM", 0 }
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[,]
{
{ new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Nimal Perera", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "nimal.perera" },
{ new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Kamal Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "kamal.silva" },
{ new Guid("29000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Ruwan Jayasuriya", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "ruwan.jayasuriya" },
{ new Guid("29000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Priyantha Kumara", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priyantha.kumara" }
});
migrationBuilder.InsertData(
table: "Vehicles",
columns: new[] { "Id", "CreatedAt", "DefaultWarehouseId", "IsActive", "NumberPlate", "RegNumber", "Type" },
values: new object[,]
{
{ new Guid("28000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000001"), true, "CAB-1234", "WP-CAB-1234", 2 },
{ new Guid("28000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000002"), true, "KY-5678", "CP-KY-5678", 1 }
});
migrationBuilder.InsertData(
table: "DriverProfiles",
columns: new[] { "Id", "CreatedAt", "FK_UserId", "IsActive", "LicenseExpiry", "LicenseNumber", "MedicalClearanceExpiry" },
values: new object[,]
{
{ new Guid("30000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), true, new DateTime(2027, 6, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B1234567", new DateTime(2027, 1, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("30000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), true, new DateTime(2027, 9, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B7654321", new DateTime(2027, 3, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "Routes",
columns: new[] { "Id", "CreatedAt", "EndPoint", "FK_OwnerUserId", "FK_RegionId", "IsActive", "Length", "RouteName", "StartPoint" },
values: new object[,]
{
{ new Guid("25000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Dehiwala", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 18.5m, "Colombo City Route", "Colombo Fort" },
{ new Guid("25000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Negombo", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 32.0m, "Negombo Coastal Route", "Wattala" },
{ new Guid("25000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Peradeniya", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 12.0m, "Kandy Town Route", "Kandy Lake" },
{ new Guid("25000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Matale", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 26.0m, "Matale Route", "Kandy" }
});
migrationBuilder.InsertData(
table: "DeliveryRuns",
columns: new[] { "Id", "ActivatedAt", "ClosedAt", "CreatedAt", "FK_DriverProfileId", "FK_RouteId", "FK_SourceWarehouseId", "FK_VehicleId", "IsActive", "Meterend", "Meterstart", "RideType", "StartedAt", "Status" },
values: new object[,]
{
{ new Guid("31000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 15, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), true, 10550, 10500, 1, new DateTime(2026, 8, 25, 8, 0, 0, 0, DateTimeKind.Unspecified), 5 },
{ new Guid("31000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 9, 20, 0, 0, DateTimeKind.Unspecified), null, new DateTime(2026, 8, 30, 8, 45, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), new Guid("25000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000002"), new Guid("28000000-0000-0000-0000-000000000002"), true, 0, 20300, 0, new DateTime(2026, 8, 30, 9, 0, 0, 0, DateTimeKind.Unspecified), 2 }
});
migrationBuilder.InsertData(
table: "Stores",
columns: new[] { "Id", "CreatedAt", "CreditAmount", "CreditLimit", "FK_RouteId", "IsActive", "KycStatus", "Latitude", "Longitude", "OneTimeAmount", "OutstandingBalance", "OwnerName", "Segment", "StoreAddress", "StoreName" },
values: new object[,]
{
{ new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 50000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.9344m, 79.8428m, 0m, 0m, "Saman Kumara", "Gold", "12 York Street, Colombo Fort", "Fort Mini Mart" },
{ new Guid("26000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 30000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.8508m, 79.8654m, 0m, 5000m, "Nadeesha Silva", "Silver", "45 Galle Road, Dehiwala", "Dehiwala Grocery" },
{ new Guid("26000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 40000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 6.9890m, 79.8912m, 0m, 1200m, "Ruwan Gunasekara", "Gold", "88 Negombo Road, Wattala", "Wattala Supermarket" },
{ new Guid("26000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 25000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 7.2094m, 79.8380m, 0m, 0m, "Chamari Peiris", null, "5 Beach Road, Negombo", "Negombo Beach Store" },
{ new Guid("26000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 35000m, new Guid("25000000-0000-0000-0000-000000000003"), true, 1, 7.2906m, 80.6337m, 0m, 0m, "Dinesh Wickramasinghe", "Key Account", "3 Lake Road, Kandy", "Kandy Lake View Store" },
{ new Guid("26000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 20000m, new Guid("25000000-0000-0000-0000-000000000004"), true, 0, 7.4675m, 80.6234m, 0m, 0m, "Ishara Bandara", null, "20 Main Street, Matale", "Matale Central Store" }
});
migrationBuilder.InsertData(
table: "LoadingSheets",
columns: new[] { "Id", "CreatedAt", "DispatchConfirmedAt", "DispatchConfirmedBy", "FK_RideId", "FK_SourceWarehouseId", "IsActive", "ReceiptConfirmedAt", "ReceiptConfirmedBy", "Status" },
values: new object[] { new Guid("35000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 25, 14, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), 2 });
migrationBuilder.InsertData(
table: "Sales",
columns: new[] { "Id", "CompletedAt", "CreatedAt", "CreatedByUserId", "FK_RideId", "FK_StoreId", "FulfillImmediately", "IsActive", "PaymentStatus", "SaleDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("32000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), false, true, 2, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 1, 3000m },
{ new Guid("32000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("31000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), false, true, 0, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "LoadingSheetItems",
columns: new[] { "Id", "FK_ItemId", "FK_LoadingSheetId", "QuantityDispatched", "QuantityReceived", "QuantityResolved" },
values: new object[,]
{
{ new Guid("36000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("35000000-0000-0000-0000-000000000001"), 50m, 50m, null },
{ new Guid("36000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("35000000-0000-0000-0000-000000000001"), 30m, 30m, null }
});
migrationBuilder.InsertData(
table: "Orders",
columns: new[] { "Id", "CreatedAt", "FK_SaleId", "FK_StoreId", "FulfilledAt", "IsActive", "OrderDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), true, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), null, true, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "DeliveryTasks",
columns: new[] { "Id", "ConfirmedAt", "CreatedAt", "CustomerConfirmed", "FK_OrderId", "FK_RideId", "IsActive", "Note", "Sequence", "Status" },
values: new object[] { new Guid("37000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 30, 10, 5, 0, 0, DateTimeKind.Unspecified), false, new Guid("33000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000002"), true, null, 1, 0 });
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "DeliveryTasks",
keyColumn: "Id",
keyValue: new Guid("37000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000006"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000006"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Brands",
keyColumn: "Id",
keyValue: new Guid("21000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Brands",
keyColumn: "Id",
keyValue: new Guid("21000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Sales",
keyColumn: "Id",
keyValue: new Guid("32000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Sales",
keyColumn: "Id",
keyValue: new Guid("32000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Uoms",
keyColumn: "Id",
keyValue: new Guid("22000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000005"));
migrationBuilder.DeleteData(
table: "DriverProfiles",
keyColumn: "Id",
keyValue: new Guid("30000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DriverProfiles",
keyColumn: "Id",
keyValue: new Guid("30000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Vehicles",
keyColumn: "Id",
keyValue: new Guid("28000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Vehicles",
keyColumn: "Id",
keyValue: new Guid("28000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Regions",
keyColumn: "Id",
keyValue: new Guid("24000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Regions",
keyColumn: "Id",
keyValue: new Guid("24000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000005"));
migrationBuilder.DeleteData(
table: "Warehouses",
keyColumn: "Id",
keyValue: new Guid("27000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Warehouses",
keyColumn: "Id",
keyValue: new Guid("27000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000001"));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,498 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedRemainingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "DriverComplianceConfigs",
columns: new[] { "Id", "EnforceLicenseExpiry", "EnforceMedicalClearance", "UpdatedAt", "UpdatedBy" },
values: new object[] { new Guid("64000000-0000-0000-0000-000000000001"), true, true, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001") });
migrationBuilder.InsertData(
table: "Payments",
columns: new[] { "Id", "Amount", "CollectedAt", "CollectedBy", "CreatedAt", "FK_SaleId", "FK_StoreId", "IsActive", "Method" },
values: new object[] { new Guid("47000000-0000-0000-0000-000000000001"), 3000m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), true, 0 });
migrationBuilder.InsertData(
table: "PriceLists",
columns: new[] { "Id", "CreatedAt", "EffectiveFrom", "EffectiveTo", "FK_RegionId", "IsActive", "Name" },
values: new object[] { new Guid("39000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "Western Province Price List" });
migrationBuilder.InsertData(
table: "RegionalBudgets",
columns: new[] { "Id", "BudgetAmount", "CreatedAt", "FK_RegionId", "IsActive", "Period" },
values: new object[] { new Guid("58000000-0000-0000-0000-000000000001"), 200000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08" });
migrationBuilder.InsertData(
table: "RegionalTargets",
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "Period", "TargetValue" },
values: new object[] { new Guid("59000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08", 500000m });
migrationBuilder.InsertData(
table: "RegionalTaxRules",
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "RuleConfig" },
values: new object[] { new Guid("60000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "{\"rate\": 0.15}" });
migrationBuilder.InsertData(
table: "RideCancelRequests",
columns: new[] { "Id", "ApprovedAt", "ApprovedBy", "FK_RideId", "Reason", "RequestedAt", "RequestedBy", "Status", "TypeCode" },
values: new object[] { new Guid("65000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000002"), "Reported minor engine issue, resolved after inspection", new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), 2, 0 });
migrationBuilder.InsertData(
table: "RouteVisitLogs",
columns: new[] { "Id", "CheckInAt", "CheckInLatitude", "CheckInLongitude", "CheckOutAt", "CreatedAt", "FK_RouteId", "FK_StoreId", "FK_UserId", "IsActive", "IsDeviationFlagged" },
values: new object[,]
{
{ new Guid("66000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 6.9344m, 79.8428m, new DateTime(2026, 8, 25, 9, 25, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000002"), true, false },
{ new Guid("66000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), 6.9000m, 79.8654m, null, new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000002"), new Guid("29000000-0000-0000-0000-000000000002"), true, true }
});
migrationBuilder.InsertData(
table: "SaleReturns",
columns: new[] { "Id", "CreatedAt", "CreatedByUserId", "FK_OrderId", "IsActive", "Mode", "TotalCreditAmount" },
values: new object[] { new Guid("51000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000001"), true, 0, 300m });
migrationBuilder.InsertData(
table: "SaleRoutes",
columns: new[] { "Id", "CreatedAt", "IsActive" },
values: new object[,]
{
{ new Guid("69000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true },
{ new Guid("69000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true }
});
migrationBuilder.InsertData(
table: "Schemes",
columns: new[] { "Id", "AgentLimitExempt", "BaselineExpectedSales", "CreatedAt", "DateRangeFrom", "DateRangeTo", "EligibleSegment", "FK_FreeItemId", "FK_RegionId", "FK_TriggerItemId", "FreeQty", "IsActive", "Name", "RegionId", "RuleConfig", "RuleType", "TriggerQty" },
values: new object[,]
{
{ new Guid("48000000-0000-0000-0000-000000000001"), false, 500000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), 1m, true, "Buy 10 Coke Get 1 Free", null, null, 0, 10 },
{ new Guid("48000000-0000-0000-0000-000000000002"), false, null, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000004"), new Guid("24000000-0000-0000-0000-000000000002"), null, 1m, true, "Snack Combo Bundle", null, null, 3, null }
});
migrationBuilder.InsertData(
table: "StockBalances",
columns: new[] { "Id", "FK_ItemId", "LocationId", "LocationType", "Quantity", "UpdatedAt" },
values: new object[,]
{
{ new Guid("61000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 150m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("61000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 90m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("61000000-0000-0000-0000-000000000003"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 20m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "StockMovements",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ItemId", "FromLocationId", "FromLocationType", "Quantity", "ReasonCode", "ReferenceId", "ToLocationId", "ToLocationType", "Type" },
values: new object[,]
{
{ new Guid("62000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), null, null, 200m, null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 6 },
{ new Guid("62000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 50m, null, new Guid("35000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 5 },
{ new Guid("62000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 30m, null, new Guid("32000000-0000-0000-0000-000000000001"), null, null, 0 }
});
migrationBuilder.InsertData(
table: "StoreKycDocuments",
columns: new[] { "Id", "CreatedAt", "DocumentType", "FK_StoreId", "FileReference", "IsActive", "UploadedAt" },
values: new object[,]
{
{ new Guid("63000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), "Business Registration", new Guid("26000000-0000-0000-0000-000000000001"), "seed/kyc/fort-mini-mart-brc.pdf", true, new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("63000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), "NIC", new Guid("26000000-0000-0000-0000-000000000003"), "seed/kyc/wattala-owner-nic.pdf", true, new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "Suppliers",
columns: new[] { "Id", "ContactInfo", "CreatedAt", "IsActive", "Name", "PerformanceScore" },
values: new object[,]
{
{ new Guid("38000000-0000-0000-0000-000000000001"), "+94 11 234 5678", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Ceylon Beverages Ltd", 4.2m },
{ new Guid("38000000-0000-0000-0000-000000000002"), "+94 11 876 5432", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Snack Foods (Pvt) Ltd", null }
});
migrationBuilder.InsertData(
table: "Uoms",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[] { new Guid("67000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Case" });
migrationBuilder.InsertData(
table: "UserRegionScopes",
columns: new[] { "FK_RegionId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000001"), true });
migrationBuilder.InsertData(
table: "UserRouteScopes",
columns: new[] { "FK_RouteId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000002"), true });
migrationBuilder.InsertData(
table: "UserStoreScopes",
columns: new[] { "FK_StoreId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000003"), true });
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[] { new Guid("70000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000006"), "Channa De Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "channa.desilva" });
migrationBuilder.InsertData(
table: "Claims",
columns: new[] { "Id", "CreatedAt", "Details", "FK_AgencyUserId", "IsActive", "SettledAt", "SettledByUserId", "SettlementAmount", "Status" },
values: new object[] { new Guid("53000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 27, 0, 0, 0, 0, DateTimeKind.Unspecified), "Distributor margin claim for Q3 promotional shortfall", new Guid("70000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 28, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 15000m, 1 });
migrationBuilder.InsertData(
table: "CreditNotes",
columns: new[] { "Id", "Amount", "CreatedAt", "FK_SaleReturnId", "IsActive", "IssuedAt" },
values: new object[] { new Guid("54000000-0000-0000-0000-000000000001"), 300m, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("51000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "PriceListItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PriceListId", "IsActive", "MinPrice", "Price" },
values: new object[,]
{
{ new Guid("40000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("39000000-0000-0000-0000-000000000001"), true, 55m, 60m },
{ new Guid("40000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("39000000-0000-0000-0000-000000000001"), true, 45m, 50m }
});
migrationBuilder.InsertData(
table: "PurchaseOrders",
columns: new[] { "Id", "CancelReason", "CreatedAt", "FK_CreatedByUserId", "FK_SupplierId", "IsActive", "Status" },
values: new object[,]
{
{ new Guid("41000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000001"), true, 2 },
{ new Guid("41000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000002"), true, 2 }
});
migrationBuilder.InsertData(
table: "RegionAgencyMaps",
columns: new[] { "Id", "CreatedAt", "FK_AgencyUserId", "FK_RegionId", "IsActive" },
values: new object[] { new Guid("57000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("70000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), true });
migrationBuilder.InsertData(
table: "SaleReturnItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SaleReturnId", "IsActive", "Quantity", "ReasonCode", "UnitPrice" },
values: new object[] { new Guid("52000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("51000000-0000-0000-0000-000000000001"), true, 5m, 1, 60m });
migrationBuilder.InsertData(
table: "SchemeBundleComponents",
columns: new[] { "Id", "FK_ItemId", "FK_SchemeId", "Qty" },
values: new object[] { new Guid("49000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("48000000-0000-0000-0000-000000000002"), 5 });
migrationBuilder.InsertData(
table: "SchemeRedemptions",
columns: new[] { "Id", "DiscountAmount", "FK_FreeItemId", "FK_OrderId", "FK_SchemeId", "FieldInvoiceId", "FreeItemCostValue", "FreeQty", "RedeemedAt" },
values: new object[] { new Guid("50000000-0000-0000-0000-000000000001"), 0m, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new Guid("48000000-0000-0000-0000-000000000001"), null, 40m, 1m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "UomConversions",
columns: new[] { "Id", "FK_FromUomId", "FK_ItemId", "FK_ToUomId", "Factor" },
values: new object[] { new Guid("67000000-0000-0000-0000-000000000002"), new Guid("67000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("22000000-0000-0000-0000-000000000001"), 24m });
migrationBuilder.InsertData(
table: "GoodsReceipts",
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "FK_ReceivedByUserId", "FK_WarehouseId", "IsActive", "ReceivedAt" },
values: new object[] { new Guid("43000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "PurchaseOrderItems",
columns: new[] { "Id", "CancelledQuantity", "CreatedAt", "FK_ItemId", "FK_PurchaseOrderId", "IsActive", "IsCancelled", "Quantity", "ReceivedQuantity", "UnitCost" },
values: new object[,]
{
{ new Guid("42000000-0000-0000-0000-000000000001"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 200, 200, 40m },
{ new Guid("42000000-0000-0000-0000-000000000002"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 100, 98, 80m },
{ new Guid("42000000-0000-0000-0000-000000000003"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("41000000-0000-0000-0000-000000000002"), true, false, 150, 150, 35m }
});
migrationBuilder.InsertData(
table: "PurchaseReturns",
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "IsActive", "Reason" },
values: new object[] { new Guid("45000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000002"), true, "Damaged in transit" });
migrationBuilder.InsertData(
table: "GoodsReceiptItems",
columns: new[] { "Id", "AcceptedQuantity", "CreatedAt", "FK_GoodsReceiptId", "FK_PurchaseOrderItemId", "IsActive", "RejectedQuantity" },
values: new object[,]
{
{ new Guid("44000000-0000-0000-0000-000000000001"), 200, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000001"), true, 0 },
{ new Guid("44000000-0000-0000-0000-000000000002"), 98, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000002"), true, 2 }
});
migrationBuilder.InsertData(
table: "PurchaseReturnItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PurchaseReturnId", "IsActive", "Quantity" },
values: new object[] { new Guid("46000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("45000000-0000-0000-0000-000000000001"), true, 10 });
migrationBuilder.InsertData(
table: "SupplierClaims",
columns: new[] { "Id", "CreatedAt", "Details", "EvidenceUrl", "FK_GoodsReceiptId", "FK_PurchaseOrderId", "IsActive", "RaisedByUserId", "ResolutionAmount", "ResolutionNotes", "ResolutionType", "ResolvedAt", "ResolvedByUserId", "Status" },
values: new object[] { new Guid("55000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), "2 units of Coke 1.5L received damaged", null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, new Guid("29000000-0000-0000-0000-000000000001"), 160m, "Credited against next invoice", 1, new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 1 });
migrationBuilder.InsertData(
table: "SupplierClaimItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SupplierClaimId", "IsActive", "IssueType", "Quantity" },
values: new object[] { new Guid("56000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("55000000-0000-0000-0000-000000000001"), true, 0, 2m });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Claims",
keyColumn: "Id",
keyValue: new Guid("53000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "CreditNotes",
keyColumn: "Id",
keyValue: new Guid("54000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DriverComplianceConfigs",
keyColumn: "Id",
keyValue: new Guid("64000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PriceListItems",
keyColumn: "Id",
keyValue: new Guid("40000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PriceListItems",
keyColumn: "Id",
keyValue: new Guid("40000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "PurchaseReturnItems",
keyColumn: "Id",
keyValue: new Guid("46000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionAgencyMaps",
keyColumn: "Id",
keyValue: new Guid("57000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalBudgets",
keyColumn: "Id",
keyValue: new Guid("58000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalTargets",
keyColumn: "Id",
keyValue: new Guid("59000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalTaxRules",
keyColumn: "Id",
keyValue: new Guid("60000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RideCancelRequests",
keyColumn: "Id",
keyValue: new Guid("65000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RouteVisitLogs",
keyColumn: "Id",
keyValue: new Guid("66000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RouteVisitLogs",
keyColumn: "Id",
keyValue: new Guid("66000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SaleReturnItems",
keyColumn: "Id",
keyValue: new Guid("52000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleRoutes",
keyColumn: "Id",
keyValue: new Guid("69000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleRoutes",
keyColumn: "Id",
keyValue: new Guid("69000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SchemeBundleComponents",
keyColumn: "Id",
keyValue: new Guid("49000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SchemeRedemptions",
keyColumn: "Id",
keyValue: new Guid("50000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "StoreKycDocuments",
keyColumn: "Id",
keyValue: new Guid("63000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StoreKycDocuments",
keyColumn: "Id",
keyValue: new Guid("63000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SupplierClaimItems",
keyColumn: "Id",
keyValue: new Guid("56000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "UomConversions",
keyColumn: "Id",
keyValue: new Guid("67000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "UserRegionScopes",
keyColumns: new[] { "FK_RegionId", "FK_UserId" },
keyValues: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "UserRouteScopes",
keyColumns: new[] { "FK_RouteId", "FK_UserId" },
keyValues: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "UserStoreScopes",
keyColumns: new[] { "FK_StoreId", "FK_UserId" },
keyValues: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "PriceLists",
keyColumn: "Id",
keyValue: new Guid("39000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseReturns",
keyColumn: "Id",
keyValue: new Guid("45000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleReturns",
keyColumn: "Id",
keyValue: new Guid("51000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Schemes",
keyColumn: "Id",
keyValue: new Guid("48000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Schemes",
keyColumn: "Id",
keyValue: new Guid("48000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SupplierClaims",
keyColumn: "Id",
keyValue: new Guid("55000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Uoms",
keyColumn: "Id",
keyValue: new Guid("67000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("70000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceipts",
keyColumn: "Id",
keyValue: new Guid("43000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrders",
keyColumn: "Id",
keyValue: new Guid("41000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseOrders",
keyColumn: "Id",
keyValue: new Guid("41000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Suppliers",
keyColumn: "Id",
keyValue: new Guid("38000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Suppliers",
keyColumn: "Id",
keyValue: new Guid("38000000-0000-0000-0000-000000000001"));
}
}
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Middleware;
@@ -144,7 +145,8 @@ builder.Services.AddCors(options =>
.AllowAnyHeader()
.AllowAnyMethod()));
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddSwaggerGen();
+23 -13
View File
@@ -50,29 +50,39 @@ it's stored for a future pricing-rule integration.
| Function reference | Endpoint | Status |
|---|---|---|
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` | Implemented |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | Implemented |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `mapAgencyToRegion(agencyId, regionId)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` | Implemented (real-time aggregation; see Open Questions, resolved) |
All request/response DTOs live in `Features/Region/RegionDTOs.cs`; routing and access checks are
in `RegionController.cs` and `RegionService.cs`.
The four `set*`/`mapAgencyToRegion` actions above were later consolidated into a single
`PUT /api/regions/{id}/manage` endpoint (`RegionService.ManageAsync`) — every field on the request
is optional, and only the sub-action(s) whose fields are present get applied, so one call can
update just one thing (as the frontend's `region-manage-sheet.tsx` does, one button per field) or
several at once. See [BACKEND_API_REFERENCE.md §6.5](../../sharedDocs/BACKEND_API_REFERENCE.md#65-regions)
for the exact request/response shape.
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
regions. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`)
is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching
`UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to
`mapAgencyToRegion`.
- `mapAgencyToRegion` additionally rejects the target user if their role level is below the
`RegionalAgency` threshold (60) — you can only map a user who actually holds (or outranks) that
role.
- ⚠️ **Temporarily disabled**: `RegionService.CanManageRegionAsync` (the gate on `ManageAsync`) was
stubbed to always return `true` — role-based restriction is deliberately deferred pending a
decision on the role model for this endpoint (tracked as a `TODO` in `RegionService.cs`). The
design below is what it enforced before being stubbed out, and what it should return to:
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
regions. Role level ≥ 60 (`RegionalAgency`) allowed unconditionally, role level ≥ 50
(`RegionalManager`) requires a matching `UserRegionScope` row for that region, anything lower
rejected.
- `agencyUserId` (when supplied to `manage`) still rejects the target user if their role level is
below the `RegionalAgency` threshold (60) — you can only map a user who actually holds (or
outranks) that role. This check was not part of the disabled gate and remains active.
---
+4 -4
View File
@@ -117,10 +117,10 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/manage` (consolidated with the three rows below), backed by the `RegionalTarget` entity. Role-level restriction (`RegionalManager`+ with region scope, or `RegionalAgency` across any region) is temporarily disabled — see `10_region_management.md`. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalBudget` entity. Same endpoint/access note as `setRegionalTarget`. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role (this check remains active even with the role gate disabled). |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. |
+19
View File
@@ -0,0 +1,19 @@
# Test Bugs & Change Tracker
Running log of backend API failures found during testing and the corresponding frontend changes needed. Add one entry per issue found; move to "Resolved" once fixed and verified.
## Open
| # | Area | Backend API Failure | Frontend Change Needed | Date |
|---|------|----------------------|--------------------------|------|
| 1 | Operations → Rides (Create Ride) | On create (not save) | Add a View button to the action column in Operations → Rides table | 2026-09-01 |
| 2 | Operations → Loading Sheets | | Add a View button to the action column in Operations → Loading Sheets table | 2026-09-01 |
| 3 | Sales (Create Sale) | Sale create not working | Update the Create Sale modal UI|Add view button to the action | 2026-09-01 |
| 4 | Orders | | Add a View button to the action column in Orders table | 2026-09-01 |
| 5 | Payments (Record Payment) | Cannot add new payment | Add a View button to the action column in Payments table | 2026-09-01 |
## Resolved
| # | Area | Backend API Failure | Frontend Change Needed | Fixed In | Date |
|---|------|----------------------|--------------------------|----------|------|
| | | | | | |
@@ -0,0 +1,270 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RegionResponse, UserResponse } from '@/types/api'
function currentPeriod() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const schema = z.object({
regionId: z.string().min(1, 'Region is required'),
period: z.string(),
targetValue: z.string(),
budgetAmount: z.string(),
ruleConfig: z.string(),
agencyUserId: z.string(),
})
type FormValues = z.infer<typeof schema>
export interface RegionManageInitialValues {
regionId: string
period?: string
targetValue?: number
budgetAmount?: number
ruleConfig?: string
agencyUserId?: string
}
interface RegionManageEntrySheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
initial?: RegionManageInitialValues | null
}
export function RegionManageEntrySheet({ open, onClose, onSuccess, initial }: RegionManageEntrySheetProps) {
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
const isEdit = !!initial
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
regionId: '',
period: currentPeriod(),
targetValue: '',
budgetAmount: '',
ruleConfig: '',
agencyUserId: '',
},
})
useEffect(() => {
if (!open) return
form.reset({
regionId: initial?.regionId ?? '',
period: initial?.period ?? currentPeriod(),
targetValue: initial?.targetValue != null ? String(initial.targetValue) : '',
budgetAmount: initial?.budgetAmount != null ? String(initial.budgetAmount) : '',
ruleConfig: initial?.ruleConfig ?? '',
agencyUserId: initial?.agencyUserId ?? '',
})
Promise.all([
regionsApi.list({ page: 1, pageSize: 200 }),
usersApi.list({ page: 1, pageSize: 200 }),
])
.then(([r, u]) => {
setRegionsList(getPaginatedItems(r).filter((x) => x.isActive))
setAgencyUsers(getPaginatedItems(u).filter((x) => x.roleName === 'RegionalAgency'))
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open, initial])
async function onSubmit(values: FormValues) {
const body: {
period?: string
targetValue?: number
budgetAmount?: number
agencyUserId?: string
ruleConfig?: string
} = {}
const target = values.targetValue.trim() ? parseFloat(values.targetValue) : NaN
const budget = values.budgetAmount.trim() ? parseFloat(values.budgetAmount) : NaN
if (!Number.isNaN(target)) {
body.period = values.period
body.targetValue = target
}
if (!Number.isNaN(budget)) {
body.period = values.period
body.budgetAmount = budget
}
if (values.ruleConfig.trim()) body.ruleConfig = values.ruleConfig
if (values.agencyUserId) body.agencyUserId = values.agencyUserId
if (Object.keys(body).length === 0) {
toast.add({ title: 'Nothing to save', description: 'Fill in at least one field.', type: 'error' })
return
}
try {
await regionsApi.manage(values.regionId, body)
toast.add({ title: isEdit ? 'Region settings updated' : 'Entry saved', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save entry', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Manage Entry' : 'Add Manage Entry'}</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="regionId"
label="Region"
required
render={({ field, invalid }) => (
<Select value={field.value as string} onValueChange={field.onChange} disabled={isEdit}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder="Select a region">
{regionsList.find((r) => r.id === field.value)?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="period"
label="Period"
required
render={({ field }) => (
<Input
type="month"
value={field.value as string}
onChange={field.onChange}
className="w-40"
/>
)}
/>
<Separator />
<FormField
control={form.control}
name="targetValue"
label="Regional Target"
required
render={({ field }) => (
<Input
type="number"
value={field.value as string}
onChange={field.onChange}
placeholder="e.g. 500000"
/>
)}
/>
<FormField
control={form.control}
name="budgetAmount"
label="Regional Budget"
required
render={({ field }) => (
<Input
type="number"
value={field.value as string}
onChange={field.onChange}
placeholder="e.g. 200000"
/>
)}
/>
<Separator />
<FormField
control={form.control}
name="ruleConfig"
label="Regional Rule Config"
required
render={({ field }) => (
<Input
value={field.value as string}
onChange={field.onChange}
placeholder='0.15'
/>
)}
/>
<Separator />
<FormField
control={form.control}
name="agencyUserId"
label="Map Regional Agency"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select an agency user">
{agencyUsers.find((u) => u.id === field.value)?.fullName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{agencyUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{agencyUsers.length === 0 && (
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
)}
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,218 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RegionManageEntryResponse, UserResponse } from '@/types/api'
import { RegionManageEntrySheet, type RegionManageInitialValues } from './region-manage-entry-sheet'
interface RegionManageRow {
regionId: string
regionName: string
period: string | null
targetValue: number | null
budgetAmount: number | null
ruleConfig: string | null
agencyUserIds: string[]
entries: RegionManageEntryResponse[]
}
function groupByRegion(entries: RegionManageEntryResponse[]): RegionManageRow[] {
const byRegion = new Map<string, RegionManageEntryResponse[]>()
for (const e of entries) {
const list = byRegion.get(e.regionId) ?? []
list.push(e)
byRegion.set(e.regionId, list)
}
const latestOf = (list: RegionManageEntryResponse[], type: RegionManageEntryResponse['entryType']) =>
list
.filter((e) => e.entryType === type)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
return Array.from(byRegion.entries()).map(([regionId, list]) => {
const target = latestOf(list, 'Target')
const budget = latestOf(list, 'Budget')
const taxRule = latestOf(list, 'TaxRule')
const agencyMaps = list.filter((e) => e.entryType === 'AgencyMap')
return {
regionId,
regionName: list[0].regionName,
period: target?.period ?? budget?.period ?? null,
targetValue: target?.amount ?? null,
budgetAmount: budget?.amount ?? null,
ruleConfig: taxRule?.ruleConfig ?? null,
agencyUserIds: agencyMaps.map((m) => m.agencyUserId).filter((id): id is string => !!id),
entries: list,
}
})
}
export function RegionManageListClient() {
const [entries, setEntries] = useState<RegionManageEntryResponse[]>([])
const [loading, setLoading] = useState(true)
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editingInitial, setEditingInitial] = useState<RegionManageInitialValues | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await regionsApi.manageEntries()
setEntries(res)
} catch (err) {
toast.add({ title: 'Failed to load manage entries', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setAgencyUsers(getPaginatedItems(res)))
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
}, [])
const rows = useMemo(() => groupByRegion(entries), [entries])
function openAdd() {
setEditingInitial(null)
setSheetOpen(true)
}
function openEdit(row: RegionManageRow) {
setEditingInitial({
regionId: row.regionId,
period: row.period ?? undefined,
targetValue: row.targetValue ?? undefined,
budgetAmount: row.budgetAmount ?? undefined,
ruleConfig: row.ruleConfig ?? undefined,
agencyUserId: row.agencyUserIds[0] ?? undefined,
})
setSheetOpen(true)
}
async function handleClear(row: RegionManageRow) {
try {
await Promise.all(
row.entries.map((e) => regionsApi.removeManageEntry(e.entryType, e.id))
)
toast.add({ title: 'Region settings cleared', type: 'success' })
fetchData()
} catch (err) {
toast.add({ title: 'Failed to clear settings', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<RegionManageRow>>(() => ({
rowKey: 'regionId',
stickyHeader: true,
emptyMessage: 'No manage entries yet. Add one to get started.',
pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'regionName',
title: 'Region',
fields: [{ key: 'regionName', primary: true }],
minWidth: 160,
},
{
key: 'period',
title: 'Period',
fields: ['period'],
width: 100,
cellRender: (row) => row.period ?? '—',
},
{
key: 'targetValue',
title: 'Regional Target',
fields: ['targetValue'],
width: 140,
cellRender: (row) => row.targetValue ?? '—',
},
{
key: 'budgetAmount',
title: 'Regional Budget',
fields: ['budgetAmount'],
width: 140,
cellRender: (row) => row.budgetAmount ?? '—',
},
{
key: 'ruleConfig',
title: 'Regional Rule Config',
fields: ['ruleConfig'],
minWidth: 180,
cellRender: (row) => row.ruleConfig ?? '—',
},
{
key: 'agencyUserIds',
title: 'Map Regional Agency',
fields: ['agencyUserIds'],
minWidth: 180,
cellRender: (row) => {
if (row.agencyUserIds.length === 0) return '—'
return row.agencyUserIds
.map((id) => agencyUsers.find((u) => u.id === id)?.fullName ?? id)
.join(', ')
},
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit region settings',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Clear region settings',
onClick: handleClear,
},
],
},
}), [agencyUsers])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Region Manage</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Targets, budgets, tax rules, and agency mappings across regions
</p>
</div>
<Button onClick={openAdd}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Entry
</Button>
</div>
<DataTable
config={tableConfig}
data={rows}
loading={loading}
/>
<RegionManageEntrySheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={fetchData}
initial={editingInitial}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RegionManageListClient } from './_components/region-manage-list-client'
export default function RegionManagePage() {
return <RegionManageListClient />
}
@@ -1,276 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type {
RegionResponse,
UserResponse,
RegionDashboardResponse,
RegionHierarchyResponse,
} from '@/types/api'
interface RegionManageSheetProps {
open: boolean
onClose: () => void
region: RegionResponse | null
}
function currentPeriod() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) {
const [period, setPeriod] = useState(currentPeriod())
const [targetValue, setTargetValue] = useState('')
const [budgetAmount, setBudgetAmount] = useState('')
const [ruleConfig, setRuleConfig] = useState('')
const [agencyUserId, setAgencyUserId] = useState('')
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
const [dashboard, setDashboard] = useState<RegionDashboardResponse | null>(null)
const [hierarchy, setHierarchy] = useState<RegionHierarchyResponse | null>(null)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
if (!open || !region) return
setPeriod(currentPeriod())
setTargetValue('')
setBudgetAmount('')
setRuleConfig('')
setAgencyUserId('')
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency')))
.catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' }))
regionsApi.hierarchy(region.id)
.then(setHierarchy)
.catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' }))
}, [open, region])
useEffect(() => {
if (!open || !region) return
regionsApi.dashboard(region.id, period)
.then(setDashboard)
.catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' }))
}, [open, region, period])
if (!region) return null
async function handleSetTarget() {
const value = parseFloat(targetValue)
if (Number.isNaN(value)) return
setSaving('target')
try {
await regionsApi.setTarget(region!.id, period, value)
toast.add({ title: 'Target updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetBudget() {
const value = parseFloat(budgetAmount)
if (Number.isNaN(value)) return
setSaving('budget')
try {
await regionsApi.setBudget(region!.id, period, value)
toast.add({ title: 'Budget updated', type: 'success' })
regionsApi.dashboard(region!.id, period).then(setDashboard)
} catch (err) {
toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleSetTaxRule() {
if (!ruleConfig.trim()) return
setSaving('tax-rule')
try {
await regionsApi.setTaxRule(region!.id, ruleConfig)
toast.add({ title: 'Tax rule updated', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
async function handleMapAgency() {
if (!agencyUserId) return
setSaving('agency')
try {
await regionsApi.mapAgency(region!.id, agencyUserId)
toast.add({ title: 'Agency mapped to region', type: 'success' })
setAgencyUserId('')
} catch (err) {
toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' })
} finally {
setSaving(null)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Manage {region.name}</DialogTitle>
<DialogDescription>
Targets, budgets, tax rules, agency mapping, and dashboard for this region.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="period">Period</Label>
<Input
id="period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-40"
/>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Regional Target</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 500000"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
/>
<Button type="button" onClick={handleSetTarget} disabled={saving === 'target'}>
{saving === 'target' ? 'Saving…' : 'Set Target'}
</Button>
</div>
{dashboard?.targetValue != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.targetValue}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label>Regional Budget</Label>
<div className="flex gap-2">
<Input
type="number"
placeholder="e.g. 200000"
value={budgetAmount}
onChange={(e) => setBudgetAmount(e.target.value)}
/>
<Button type="button" onClick={handleSetBudget} disabled={saving === 'budget'}>
{saving === 'budget' ? 'Saving…' : 'Set Budget'}
</Button>
</div>
{dashboard?.budgetAmount != null && (
<p className="text-xs text-muted-foreground">Current: {dashboard.budgetAmount}</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Tax Rule Config (JSON)</Label>
<div className="flex gap-2">
<Input
placeholder='e.g. {"rate": 0.15}'
value={ruleConfig}
onChange={(e) => setRuleConfig(e.target.value)}
/>
<Button type="button" onClick={handleSetTaxRule} disabled={saving === 'tax-rule'}>
{saving === 'tax-rule' ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Map Regional Agency</Label>
<div className="flex gap-2">
<Select value={agencyUserId} onValueChange={(v) => setAgencyUserId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select an agency user">
{agencyUsers.find((u) => u.id === agencyUserId)?.fullName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{agencyUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={handleMapAgency} disabled={saving === 'agency'}>
{saving === 'agency' ? 'Saving…' : 'Map'}
</Button>
</div>
{agencyUsers.length === 0 && (
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Hierarchy</Label>
{hierarchy && hierarchy.routes.length > 0 ? (
<ul className="text-sm space-y-1">
{hierarchy.routes.map((r) => (
<li key={r.routeId}>
{r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">No routes under this region yet.</p>
)}
</div>
<Separator />
<div className="flex flex-col gap-2">
<Label>Dashboard ({period})</Label>
{dashboard ? (
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Orders: {dashboard.orderCount}</div>
<div>Actual Sales: {dashboard.actualSales}</div>
<div>Target: {dashboard.targetValue ?? '—'}</div>
<div>Budget: {dashboard.budgetAmount ?? '—'}</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Loading dashboard…</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -13,7 +13,6 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
import type { RegionResponse } from '@/types/api'
import { RegionSheet } from './region-sheet'
import { RegionManageSheet } from './region-manage-sheet'
export function RegionsClient() {
const [data, setData] = useState<RegionResponse[]>([])
@@ -25,9 +24,6 @@ export function RegionsClient() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RegionResponse | null>(null)
const [manageOpen, setManageOpen] = useState(false)
const [managing, setManaging] = useState<RegionResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
@@ -55,11 +51,6 @@ export function RegionsClient() {
setSheetOpen(true)
}
function openManage(region: RegionResponse) {
setManaging(region)
setManageOpen(true)
}
async function toggleActive(region: RegionResponse) {
try {
await regionsApi.update(region.id, { isActive: !region.isActive })
@@ -112,11 +103,6 @@ export function RegionsClient() {
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Manage targets, budgets & agencies',
onClick: openManage,
},
{
action: 'edit',
label: 'Edit region',
@@ -136,7 +122,7 @@ export function RegionsClient() {
},
],
},
}), [pageSize, openEdit, openManage, toggleActive])
}), [pageSize, openEdit, toggleActive])
return (
<>
@@ -170,12 +156,6 @@ export function RegionsClient() {
region={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
<RegionManageSheet
open={manageOpen}
onClose={() => setManageOpen(false)}
region={managing}
/>
</>
)
}
@@ -0,0 +1,334 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const LocationPicker = dynamic(
() => import('../../_components/location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 520 }} /> }
)
const schema = z.object({
storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'),
storeAddress: z.string().min(1, 'Store address is required'),
routeId: z.string().min(1, 'Route is required'),
latitude: z.number(),
longitude: z.number(),
creditLimit: z.number().min(0),
segment: z.string().optional(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
// ─── Inner form — only mounts once store data is available ───────────────────
interface StoreEditFormProps {
store: StoreResponse
routes: RouteResponse[]
onSaved: () => void
onCancel: () => void
}
function StoreEditForm({ store, routes, onSaved, onCancel }: StoreEditFormProps) {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
storeName: store.storeName,
ownerName: store.ownerName,
storeAddress: store.storeAddress,
routeId: store.routeId,
latitude: store.latitude,
longitude: store.longitude,
creditLimit: store.creditLimit,
segment: store.segment ?? '',
isActive: store.isActive,
},
})
const lat = form.watch('latitude')
const lng = form.watch('longitude')
async function onSubmit(values: FormValues) {
try {
await storesApi.update(store.id, {
...values,
segment: values.segment || undefined,
})
toast.add({ title: 'Store updated', type: 'success' })
onSaved()
} catch (err) {
toast.add({ title: 'Failed to update store', description: getApiError(err), type: 'error' })
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — text fields */}
<div className="lg:col-span-1">
<FieldGroup>
<FormField
control={form.control}
name="storeName"
label="Store Name"
required
render={({ field, invalid }) => (
<Input
id="storeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Sunrise Mart"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ownerName"
label="Owner Name"
required
render={({ field, invalid }) => (
<Input
id="ownerName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kamal Perera"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeAddress"
label="Store Address"
required
render={({ field, invalid }) => (
<Input
id="storeAddress"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 123 Main St, Colombo"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a route" />
</SelectTrigger>
<SelectContent>
{routes.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="creditLimit"
label="Credit Limit"
render={({ field, invalid }) => (
<Input
id="creditLimit"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="segment"
label="Segment"
render={({ field, invalid }) => (
<Input
id="segment"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Retail"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</div>
</FieldGroup>
</div>
{/* Right — map */}
<div className="flex flex-col gap-2 lg:col-span-2">
<div>
<p className="text-sm font-medium text-foreground">
Store Location <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground">
Click the map or drag the pin to update the location.
</p>
</div>
<LocationPicker
lat={lat}
lng={lng}
height={520}
onChange={(newLat, newLng) => {
form.setValue('latitude', newLat, { shouldValidate: true })
form.setValue('longitude', newLng, { shouldValidate: true })
}}
/>
{(lat !== 0 || lng !== 0) && (
<p className="text-xs text-muted-foreground">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
)}
</div>
</div>
</form>
)
}
// ─── Page — fetches data, then hands off to the form ─────────────────────────
export default function EditStorePage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [store, setStore] = useState<StoreResponse | null>(null)
const [routes, setRoutes] = useState<RouteResponse[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
storesApi.get(id),
routesApi.list({ pageSize: 100 }),
])
.then(([s, r]) => {
setStore(s)
setRoutes(getPaginatedItems(r))
})
.catch((err) =>
toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
)
.finally(() => setLoading(false))
}, [id])
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => router.push('/stores')}
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Edit Store</h1>
<p className="text-sm text-muted-foreground">Update the store details below.</p>
</div>
</div>
{/* Skeleton while loading */}
{loading && (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="animate-pulse rounded-2xl bg-muted lg:col-span-2" style={{ height: 520 }} />
</div>
)}
{/* Form — only mounts after store data is available */}
{!loading && store && (
<StoreEditForm
store={store}
routes={routes}
onSaved={() => router.push('/stores')}
onCancel={() => router.push('/stores')}
/>
)}
</div>
)
}
@@ -0,0 +1,175 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { stores as storesApi, getApiError } from '@/lib/api/index'
import type { StoreResponse, KycStatus } from '@/types/api'
const LocationPicker = dynamic(
() => import('../_components/location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
)
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtMoney = (v: number) =>
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
function kycVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
if (status === 'Verified') return 'default'
if (status === 'Rejected') return 'destructive'
return 'secondary'
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 py-3 border-b last:border-0">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium text-foreground">{children}</span>
</div>
)
}
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-2xl border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
)
}
export default function StoreViewPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [store, setStore] = useState<StoreResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
storesApi.get(id)
.then(setStore)
.catch((err) =>
toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
)
.finally(() => setLoading(false))
}, [id])
const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/stores')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{loading ? (
<div className="h-7 w-48 animate-pulse rounded-lg bg-muted" />
) : (
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-semibold text-foreground">{store?.storeName}</h1>
{store && (
<>
<Badge variant={store.isActive ? 'default' : 'secondary'}>
{store.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
</>
)}
</div>
)}
<p className="text-sm text-muted-foreground mt-0.5">Store details and location</p>
</div>
</div>
<Button onClick={() => router.push(`/stores/${id}/edit`)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit Store
</Button>
</div>
{/* Body skeleton */}
{loading && (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-2 lg:col-span-1">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-2xl bg-muted" />
))}
</div>
<div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} />
</div>
</div>
)}
{/* Body */}
{!loading && store && (
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — store details */}
<div className="lg:col-span-1">
<div className="rounded-2xl border px-4">
<DetailRow label="Owner">{store.ownerName}</DetailRow>
<DetailRow label="Address">{store.storeAddress}</DetailRow>
<DetailRow label="Route ID">
<span className="font-mono text-xs">{store.routeId}</span>
</DetailRow>
{store.segment && (
<DetailRow label="Segment">{store.segment}</DetailRow>
)}
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
<DetailRow label="KYC Status">
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
</DetailRow>
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
</div>
</div>
{/* Right — credit stats + map */}
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
<StatCard
label="Credit Limit"
value={fmtMoney(store.creditLimit)}
/>
<StatCard
label="Outstanding"
value={fmtMoney(store.outstandingBalance)}
/>
<StatCard
label="Available Credit"
value={fmtMoney(availableCredit)}
sub={availableCredit < 0 ? 'Over limit' : undefined}
/>
</div>
<LocationPicker
lat={store.latitude}
lng={store.longitude}
height={460}
readOnly
/>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,185 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Circle, CircleMarker, useMapEvents, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const pinIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
interface UserLocation {
lat: number
lng: number
accuracy: number
}
interface LocationPickerProps {
lat: number
lng: number
onChange?: (lat: number, lng: number) => void
height?: number
readOnly?: boolean
showUserLocation?: boolean
}
function ClickHandler({ onChange }: { onChange: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onChange(e.latlng.lat, e.latlng.lng)
},
})
return null
}
// Recenters the map once when coords first become non-zero (e.g. after geolocation resolves)
function AutoCenter({ lat, lng }: { lat: number; lng: number }) {
const map = useMap()
const applied = useRef(false)
useEffect(() => {
if (!applied.current && (lat !== 0 || lng !== 0)) {
applied.current = true
map.setView([lat, lng], 15)
}
}, [lat, lng, map])
return null
}
// Sri Lanka geographic center — fallback when no coords are available yet
const SRI_LANKA: [number, number] = [7.8731, 80.7718]
export function LocationPicker({
lat,
lng,
onChange,
height = 300,
readOnly = false,
showUserLocation = false,
}: LocationPickerProps) {
const geoCalled = useRef(false)
const watchId = useRef<number | null>(null)
const [userLoc, setUserLoc] = useState<UserLocation | null>(null)
useEffect(() => {
if (readOnly || !onChange) return
if (!geoCalled.current && lat === 0 && lng === 0) {
geoCalled.current = true
navigator.geolocation?.getCurrentPosition(
(pos) => onChange(pos.coords.latitude, pos.coords.longitude),
() => {},
{ enableHighAccuracy: true, timeout: 8000 }
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Live user location tracking — only when requested
useEffect(() => {
if (!showUserLocation || !navigator.geolocation) return
watchId.current = navigator.geolocation.watchPosition(
(pos) => {
setUserLoc({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
})
},
() => {},
{ enableHighAccuracy: true }
)
return () => {
if (watchId.current !== null) {
navigator.geolocation.clearWatch(watchId.current)
}
}
}, [showUserLocation])
const hasPin = lat !== 0 || lng !== 0
const initialCenter: [number, number] = hasPin ? [lat, lng] : SRI_LANKA
const initialZoom = hasPin ? 15 : 8
return (
<div className="isolate overflow-hidden rounded-2xl border" style={{ height }}>
<MapContainer
center={initialCenter}
zoom={initialZoom}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom
>
{process.env.NEXT_PUBLIC_MAPTILER_KEY ? (
<TileLayer
attribution='&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url={`https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY}`}
tileSize={512}
zoomOffset={-1}
maxZoom={22}
/>
) : (
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
maxZoom={19}
/>
)}
{!readOnly && onChange && <ClickHandler onChange={onChange} />}
<AutoCenter lat={lat} lng={lng} />
{/* Store location pin */}
{hasPin && (
<Marker
position={[lat, lng]}
icon={pinIcon}
draggable={!readOnly}
eventHandlers={
!readOnly && onChange
? {
dragend(e) {
const pos = (e.target as L.Marker).getLatLng()
onChange(pos.lat, pos.lng)
},
}
: {}
}
/>
)}
{/* User's live location — accuracy halo + solid dot */}
{showUserLocation && userLoc && (
<>
<Circle
center={[userLoc.lat, userLoc.lng]}
radius={userLoc.accuracy}
pathOptions={{
color: '#2563eb',
fillColor: '#3b82f6',
fillOpacity: 0.15,
weight: 1,
}}
/>
<CircleMarker
center={[userLoc.lat, userLoc.lng]}
radius={7}
pathOptions={{
color: '#ffffff',
fillColor: '#2563eb',
fillOpacity: 1,
weight: 2,
}}
/>
</>
)}
</MapContainer>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
@@ -26,6 +27,12 @@ import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
// Leaflet uses browser APIs — must be loaded client-side only
const LocationPicker = dynamic(
() => import('./location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="h-[300px] animate-pulse rounded-2xl bg-muted" /> }
)
const schema = z.object({
storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'),
@@ -43,13 +50,11 @@ type FormValues = z.infer<typeof schema>
interface StoreSheetProps {
open: boolean
onClose: () => void
store: StoreResponse | null
store: StoreResponse
onSuccess: () => void
}
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({
@@ -67,53 +72,36 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
},
})
const lat = form.watch('latitude')
const lng = form.watch('longitude')
useEffect(() => {
if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
form.reset(
store
? {
storeName: store.storeName,
ownerName: store.ownerName,
storeAddress: store.storeAddress,
routeId: store.routeId,
latitude: store.latitude,
longitude: store.longitude,
creditLimit: store.creditLimit,
segment: store.segment ?? '',
isActive: store.isActive,
}
: {
storeName: '',
ownerName: '',
storeAddress: '',
routeId: '',
latitude: 0,
longitude: 0,
creditLimit: 0,
segment: '',
isActive: true,
}
)
form.reset({
storeName: store.storeName,
ownerName: store.ownerName,
storeAddress: store.storeAddress,
routeId: store.routeId,
latitude: store.latitude,
longitude: store.longitude,
creditLimit: store.creditLimit,
segment: store.segment ?? '',
isActive: store.isActive,
})
}
}, [open, store])
async function onSubmit(values: FormValues) {
try {
const body = {
await storesApi.update(store.id, {
...values,
segment: values.segment || undefined,
}
if (isEdit) {
await storesApi.update(store!.id, body)
toast.add({ title: 'Store updated', type: 'success' })
} else {
await storesApi.create(body)
toast.add({ title: 'Store created', type: 'success' })
}
})
toast.add({ title: 'Store updated', type: 'success' })
onSuccess()
onClose()
} catch (err) {
@@ -123,12 +111,10 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Store' : 'Add Store'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
</DialogDescription>
<DialogTitle>Edit Store</DialogTitle>
<DialogDescription>Update the store details below.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -211,43 +197,32 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
)}
/>
<FormField
control={form.control}
name="latitude"
label="Latitude"
render={({ field, invalid }) => (
<Input
id="latitude"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 6.9271"
aria-invalid={invalid}
/>
)}
/>
{/* Location picker — replaces manual lat/lng inputs */}
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-foreground">
Store Location <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground">
Click the map or drag the pin to update the location.
</p>
</div>
<FormField
control={form.control}
name="longitude"
label="Longitude"
render={({ field, invalid }) => (
<Input
id="longitude"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 79.8612"
aria-invalid={invalid}
/>
<LocationPicker
lat={lat}
lng={lng}
onChange={(newLat, newLng) => {
form.setValue('latitude', newLat, { shouldValidate: true })
form.setValue('longitude', newLng, { shouldValidate: true })
}}
/>
{(lat !== 0 || lng !== 0) && (
<p className="text-xs text-muted-foreground">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
)}
/>
</div>
<FormField
control={form.control}
@@ -309,7 +284,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Store'}
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -22,6 +23,7 @@ function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destruct
}
export function StoresClient() {
const router = useRouter()
const [data, setData] = useState<StoreResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -128,10 +130,15 @@ export function StoresClient() {
],
rowActions: {
actions: () => [
{
action: 'view',
label: 'View store',
onClick: (store) => router.push(`/stores/${store.id}`),
},
{
action: 'edit',
label: 'Edit store',
onClick: openEdit,
onClick: (store) => router.push(`/stores/${store.id}/edit`),
},
{
action: 'confirm',
@@ -163,7 +170,7 @@ export function StoresClient() {
Retail store accounts, credit limits, and KYC status
</p>
</div>
<Button onClick={openCreate}>
<Button onClick={() => router.push('/stores/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Store
</Button>
@@ -0,0 +1,294 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse } from '@/types/api'
const LocationPicker = dynamic(
() => import('../_components/location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="h-full min-h-[360px] animate-pulse rounded-2xl bg-muted" /> }
)
const schema = z.object({
storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'),
storeAddress: z.string().min(1, 'Store address is required'),
routeId: z.string().min(1, 'Route is required'),
latitude: z.number(),
longitude: z.number(),
creditLimit: z.number().min(0),
segment: z.string().optional(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
export default function NewStorePage() {
const router = useRouter()
const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
storeName: '',
ownerName: '',
storeAddress: '',
routeId: '',
latitude: 0,
longitude: 0,
creditLimit: 0,
segment: '',
isActive: true,
},
})
const lat = form.watch('latitude')
const lng = form.watch('longitude')
useEffect(() => {
routesApi.list({ pageSize: 100 })
.then((res) => setRoutes(getPaginatedItems(res)))
.catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
}, [])
async function onSubmit(values: FormValues) {
try {
await storesApi.create({
...values,
segment: values.segment || undefined,
})
toast.add({ title: 'Store created', type: 'success' })
router.push('/stores')
} catch (err) {
toast.add({ title: 'Failed to create store', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => router.push('/stores')}
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Add Store</h1>
<p className="text-sm text-muted-foreground">Add a new retail store to the network.</p>
</div>
</div>
{/* Form */}
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — text fields */}
<div className="lg:col-span-1">
<FieldGroup>
<FormField
control={form.control}
name="storeName"
label="Store Name"
required
render={({ field, invalid }) => (
<Input
id="storeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Sunrise Mart"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ownerName"
label="Owner Name"
required
render={({ field, invalid }) => (
<Input
id="ownerName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kamal Perera"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeAddress"
label="Store Address"
required
render={({ field, invalid }) => (
<Input
id="storeAddress"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 123 Main St, Colombo"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a route" />
</SelectTrigger>
<SelectContent>
{routes.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="creditLimit"
label="Credit Limit"
render={({ field, invalid }) => (
<Input
id="creditLimit"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="segment"
label="Segment"
render={({ field, invalid }) => (
<Input
id="segment"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Retail"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={() => router.push('/stores')}
>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create Store'}
</Button>
</div>
</FieldGroup>
</div>
{/* Right — map */}
<div className="flex flex-col gap-2 lg:col-span-2">
<div>
<p className="text-sm font-medium text-foreground">
Store Location <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground">
Your current location is used as the starting point. Click the map or drag the pin to adjust.
</p>
</div>
<div className="flex-1">
<LocationPicker
lat={lat}
lng={lng}
height={520}
showUserLocation
onChange={(newLat, newLng) => {
form.setValue('latitude', newLat, { shouldValidate: true })
form.setValue('longitude', newLng, { shouldValidate: true })
}}
/>
</div>
{(lat !== 0 || lng !== 0) && (
<p className="text-xs text-muted-foreground">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
)}
</div>
</div>
</form>
</div>
)
}
@@ -4,6 +4,7 @@ export type SortDirection = 'asc' | 'desc' | null;
export type ExportFormat = 'csv' | 'excel' | 'pdf';
export type ColumnAlign = 'left' | 'center' | 'right';
export type RowActionKind =
| 'view'
| 'edit'
| 'activate'
| 'deactivate'
@@ -6,6 +6,7 @@ import {
Cancel01Icon,
CheckmarkCircle02Icon,
Delete02Icon,
EyeIcon,
PencilEdit01Icon,
} from "@hugeicons/core-free-icons"
@@ -19,6 +20,7 @@ const ACTION_ICONS = {
confirm: CheckmarkCircle02Icon,
deactivate: Cancel01Icon,
edit: PencilEdit01Icon,
view: EyeIcon,
} as const
type RowActionKind = keyof typeof ACTION_ICONS
+1
View File
@@ -138,6 +138,7 @@ export const NAV_GROUPS: NavGroup[] = [
icon: Building02Icon,
items: [
{ title: 'Regions', href: '/network/regions', icon: Location01Icon, description: 'Geographic territories the distribution network operates in', permission: 'regions.view' },
{ title: 'Region Manage', href: '/network/region-manage', icon: ClipboardListIcon, description: 'Targets, budgets, tax rules, and agency mapping per region', permission: 'regions.view' },
{ title: 'Routes', href: '/network/routes', icon: Route01Icon, description: 'Delivery paths within a region that rides are assigned to', permission: 'routes.view' },
{ title: 'Vehicles', href: '/network/vehicles', icon: Car01Icon, description: 'Fleet vehicles available for delivery rides', permission: 'vehicles.view' },
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
+13 -13
View File
@@ -2,12 +2,11 @@ import { api } from '@/lib/api'
import type {
PaginatedResponse,
RegionResponse,
RegionalTargetResponse,
RegionalBudgetResponse,
RegionAgencyMapResponse,
RegionalTaxRuleResponse,
ManageRegionResponse,
RegionHierarchyResponse,
RegionDashboardResponse,
RegionManageEntryResponse,
RegionManageEntryType,
RouteResponse,
RouteVisitResponse,
VehicleResponse,
@@ -104,17 +103,18 @@ export const regions = {
remove: (id: string) =>
api.delete(`/regions/${id}`),
setTarget: (id: string, period: string, targetValue: number) =>
api.put<RegionalTargetResponse>(`/regions/${id}/targets/${period}`, { targetValue }).then(r => r.data),
// Consolidated replacement for the old separate setTarget/setBudget/mapAgency/setTaxRule
// calls — pass only the fields you want to change (Period is required alongside targetValue
// and/or budgetAmount).
manage: (id: string, body: { period?: string; targetValue?: number; budgetAmount?: number; agencyUserId?: string; ruleConfig?: string }) =>
api.put<ManageRegionResponse>(`/regions/${id}/manage`, body).then(r => r.data),
setBudget: (id: string, period: string, budgetAmount: number) =>
api.put<RegionalBudgetResponse>(`/regions/${id}/budgets/${period}`, { budgetAmount }).then(r => r.data),
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
manageEntries: () =>
api.get<RegionManageEntryResponse[]>('/regions/manage-entries').then(r => r.data),
mapAgency: (id: string, agencyUserId: string) =>
api.post<RegionAgencyMapResponse>(`/regions/${id}/agencies`, { agencyUserId }).then(r => r.data),
setTaxRule: (id: string, ruleConfig: string) =>
api.put<RegionalTaxRuleResponse>(`/regions/${id}/tax-rule`, { ruleConfig }).then(r => r.data),
removeManageEntry: (entryType: RegionManageEntryType, id: string) =>
api.delete(`/regions/manage-entries/${entryType}/${id}`),
hierarchy: (id: string) =>
api.get<RegionHierarchyResponse>(`/regions/${id}/hierarchy`).then(r => r.data),
+49
View File
@@ -12,14 +12,17 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
@@ -1980,6 +1983,17 @@
"node": ">=12.4.0"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2384,6 +2398,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2398,6 +2418,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -6621,6 +6650,12 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8235,6 +8270,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/recast": {
"version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
+3
View File
@@ -16,14 +16,17 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
+24
View File
@@ -125,6 +125,14 @@ export interface RegionalTaxRuleResponse {
ruleConfig: string
}
export interface ManageRegionResponse {
regionId: string
target: RegionalTargetResponse | null
budget: RegionalBudgetResponse | null
agencyMap: RegionAgencyMapResponse | null
taxRule: RegionalTaxRuleResponse | null
}
export interface RegionHierarchyRouteItem {
routeId: string
routeName: string
@@ -145,6 +153,22 @@ export interface RegionDashboardResponse {
orderCount: number
actualSales: number
lastOrderDate: string | null
taxRuleConfig: string | null
agencyUserIds: string[]
}
export type RegionManageEntryType = 'Target' | 'Budget' | 'TaxRule' | 'AgencyMap'
export interface RegionManageEntryResponse {
id: string
regionId: string
regionName: string
entryType: RegionManageEntryType
period: string | null
amount: number | null
ruleConfig: string | null
agencyUserId: string | null
createdAt: string
}
// ─── Vehicles / Warehouses ───────────────────────────────────────────────────
+39 -42
View File
@@ -506,54 +506,44 @@ Array of:
}
```
**Beyond CRUD** — targets, budgets, agency mapping, tax rules, hierarchy, and dashboard. These are
not gated by the `regions.*` permission codes above; access is checked by role level in
`RegionService`: `RegionalManager` (level 50) can act only on a region they have scope over (via
`UserRegionScope`), `RegionalAgency` (level 60) can act on any region.
**Beyond CRUD** — manage (targets/budgets/agency mapping/tax rules, consolidated), hierarchy, and
dashboard. Not gated by the `regions.*` permission codes above.
> ⚠️ **Authorization temporarily disabled**: `ManageAsync` currently allows any authenticated
> caller — the previous role-level gate (`RegionalManager`, level 50, scoped via
> `UserRegionScope`; `RegionalAgency`, level 60, any region) was deliberately stubbed out
> (`CanManageRegionAsync` always returns `true`) pending a decision on the role model for this
> endpoint. Re-enabling it is tracked as a TODO in `RegionService.cs`.
| Method | Path | Description |
|---|---|---|
| `PUT` | `/api/regions/{id}/targets/{period}` | Set the sales target for a region for a period (`period` = `yyyy-MM`) |
| `PUT` | `/api/regions/{id}/budgets/{period}` | Set the budget for a region for a period |
| `POST` | `/api/regions/{id}/agencies` | Map an existing user (holding the `RegionalAgency` role) to this region |
| `PUT` | `/api/regions/{id}/tax-rule` | Set/replace the region's tax rule config |
| `PUT` | `/api/regions/{id}/manage` | Set target/budget/agency-map/tax-rule in one call — replaces the old separate targets/budgets/agencies/tax-rule endpoints |
| `GET` | `/api/regions/{id}/hierarchy` | Region's routes and each route's store ids |
| `GET` | `/api/regions/{id}/dashboard?period=yyyy-MM` | Real-time order count/sales/target/budget for the period |
**Set target request / response:**
**Manage request** — every field is optional; only the sub-actions whose fields are present get
applied, so a caller can update just one thing or several in a single request. `period` is
required alongside `targetValue` and/or `budgetAmount` (both are silently skipped without it):
```json
// PUT /api/regions/{id}/targets/{period}
{ "targetValue": 500000 }
```
```json
{ "regionId": "guid", "period": "2026-08", "targetValue": 500000 }
// PUT /api/regions/{id}/manage
{
"period": "2026-08", // required for targetValue/budgetAmount
"targetValue": 500000, // optional
"budgetAmount": 200000, // optional
"agencyUserId": "guid", // optional — must be a User with the RegionalAgency role
"ruleConfig": "{\"rate\": 0.15}" // optional — arbitrary JSON stored as a string, not yet consumed by pricing
}
```
**Set budget request / response:**
**Manage response** — each field is `null` if that sub-action wasn't requested:
```json
// PUT /api/regions/{id}/budgets/{period}
{ "budgetAmount": 200000 }
```
```json
{ "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 }
```
**Map agency request / response:**
```json
// POST /api/regions/{id}/agencies
{ "agencyUserId": "guid" } // must be a User with the RegionalAgency role
```
```json
{ "regionId": "guid", "agencyUserId": "guid" }
```
**Set tax rule request / response:**
```json
// PUT /api/regions/{id}/tax-rule
{ "ruleConfig": "{\"rate\": 0.15}" } // arbitrary JSON stored as a string; not yet consumed by pricing
```
```json
{ "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" }
{
"regionId": "guid",
"target": { "regionId": "guid", "period": "2026-08", "targetValue": 500000 } | null,
"budget": { "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 } | null,
"agencyMap": { "regionId": "guid", "agencyUserId": "guid" } | null,
"taxRule": { "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" } | null
}
```
**Hierarchy response:**
@@ -576,13 +566,20 @@ not gated by the `regions.*` permission codes above; access is checked by role l
"budgetAmount": 200000, // null if no budget set for the period
"orderCount": 42,
"actualSales": 187500.00,
"lastOrderDate": "datetime | null"
"lastOrderDate": "datetime | null",
"taxRuleConfig": "{\"rate\": 0.15}" | null, // current tax rule, independent of period
"agencyUserIds": ["guid", "..."] // all agency users currently mapped to this region
}
```
`taxRuleConfig` and `agencyUserIds` are not period-scoped — they reflect current state regardless
of the `period` query param, added so the frontend's region-manage dialog has something to render
after calling `manage` (previously the dashboard only echoed back target/budget, leaving tax-rule
and agency-mapping changes invisible in the UI after saving).
All four of these endpoints return `404` if the region doesn't exist, the caller lacks the
required region scope, or (for agency mapping) the target user doesn't hold the `RegionalAgency`
role — the response body carries no distinction between "not found" and "forbidden".
All three of these endpoints return `404` if the region doesn't exist, or (for `manage`, when
`agencyUserId` is supplied) the target user doesn't hold the `RegionalAgency` role — the response
body carries no distinction between "not found" and "invalid agency user". (The former
"forbidden due to region scope" case doesn't currently apply — see the authorization note above.)
---