From 7cf272e311d51518f5fd847476a0f0163bf63e8c Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 1 Sep 2026 15:34:58 +0530 Subject: [PATCH] Consolidate region management APIs --- .../Features/Region/IRegionRepository.cs | 14 + .../Features/Region/RegionController.cs | 40 +-- Distribution/Features/Region/RegionDTOs.cs | 45 ++- .../Features/Region/RegionRepository.cs | 66 ++++- Distribution/Features/Region/RegionService.cs | 262 ++++++++++------- Distribution/docs/10_region_management.md | 36 ++- Distribution/docs/mainPlan.md | 8 +- .../_components/region-manage-entry-sheet.tsx | 265 +++++++++++++++++ .../_components/region-manage-list-client.tsx | 218 ++++++++++++++ .../network/region-manage/page.tsx | 5 + .../_components/region-manage-sheet.tsx | 276 ------------------ .../regions/_components/regions-client.tsx | 22 +- distribution_fe/config/nav.ts | 1 + distribution_fe/lib/api/master.ts | 26 +- distribution_fe/types/api.ts | 24 ++ sharedDocs/BACKEND_API_REFERENCE.md | 81 +++-- 16 files changed, 880 insertions(+), 509 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-entry-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-list-client.tsx create mode 100644 distribution_fe/app/(dashboard)/network/region-manage/page.tsx delete mode 100644 distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx diff --git a/Distribution/Features/Region/IRegionRepository.cs b/Distribution/Features/Region/IRegionRepository.cs index 43e0382..de27c38 100644 --- a/Distribution/Features/Region/IRegionRepository.cs +++ b/Distribution/Features/Region/IRegionRepository.cs @@ -11,17 +11,31 @@ public interface IRegionRepository Task GetTargetAsync(Guid regionId, string period); Task AddTargetAsync(Models.RegionalTarget target); void UpdateTarget(Models.RegionalTarget target); + Task> GetAllTargetsAsync(); + Task GetTargetByIdAsync(Guid id); + void RemoveTarget(Models.RegionalTarget target); Task GetBudgetAsync(Guid regionId, string period); Task AddBudgetAsync(Models.RegionalBudget budget); void UpdateBudget(Models.RegionalBudget budget); + Task> GetAllBudgetsAsync(); + Task GetBudgetByIdAsync(Guid id); + void RemoveBudget(Models.RegionalBudget budget); Task GetAgencyMapAsync(Guid regionId, Guid agencyUserId); Task AddAgencyMapAsync(Models.RegionAgencyMap map); + void UpdateAgencyMap(Models.RegionAgencyMap map); + Task> GetAgencyMapsForRegionAsync(Guid regionId); + Task> GetAllAgencyMapsAsync(); + Task GetAgencyMapByIdAsync(Guid id); + void RemoveAgencyMap(Models.RegionAgencyMap map); Task GetTaxRuleAsync(Guid regionId); Task AddTaxRuleAsync(Models.RegionalTaxRule rule); void UpdateTaxRule(Models.RegionalTaxRule rule); + Task> GetAllTaxRulesAsync(); + Task GetTaxRuleByIdAsync(Guid id); + void RemoveTaxRule(Models.RegionalTaxRule rule); Task GetUserRoleLevelAsync(Guid userId); diff --git a/Distribution/Features/Region/RegionController.cs b/Distribution/Features/Region/RegionController.cs index 7fe260a..c21dbd9 100644 --- a/Distribution/Features/Region/RegionController.cs +++ b/Distribution/Features/Region/RegionController.cs @@ -38,31 +38,11 @@ public class RegionController(RegionService service) : ControllerBase return deleted ? NoContent() : NotFound(); } - [HttpPut("{id:guid}/targets/{period}")] - public async Task SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct) + //targets/budgets/agencies/tax-rule. + [HttpPut("{id:guid}/manage")] + public async Task 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 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 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 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 GetManageEntries() => + Ok(await service.GetManageEntriesAsync()); + + [HttpDelete("manage-entries/{entryType}/{id:guid}")] + public async Task DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct) + { + var deleted = await service.DeleteManageEntryAsync(entryType, id, ct); + return deleted ? NoContent() : NotFound(); + } } diff --git a/Distribution/Features/Region/RegionDTOs.cs b/Distribution/Features/Region/RegionDTOs.cs index 08a1f5c..896f0f2 100644 --- a/Distribution/Features/Region/RegionDTOs.cs +++ b/Distribution/Features/Region/RegionDTOs.cs @@ -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 StoreIds); public record RegionHierarchyResponse(Guid RegionId, string RegionName, List Routes); @@ -33,4 +42,22 @@ public record RegionDashboardResponse( decimal? BudgetAmount, int OrderCount, decimal ActualSales, - DateTime? LastOrderDate); + DateTime? LastOrderDate, + string? TaxRuleConfig, + List 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); diff --git a/Distribution/Features/Region/RegionRepository.cs b/Distribution/Features/Region/RegionRepository.cs index 4e357e0..428ad5e 100644 --- a/Distribution/Features/Region/RegionRepository.cs +++ b/Distribution/Features/Region/RegionRepository.cs @@ -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 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> GetAllTargetsAsync() => + await context.RegionalTargets.ToListAsync(); + + public async Task 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 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> GetAllBudgetsAsync() => + await context.RegionalBudgets.ToListAsync(); + + public async Task 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 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> GetAgencyMapsForRegionAsync(Guid regionId) => + await context.RegionAgencyMaps.Where(m => m.FK_RegionId == regionId).ToListAsync(); + + public async Task> GetAllAgencyMapsAsync() => + await context.RegionAgencyMaps.ToListAsync(); + + public async Task 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 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> GetAllTaxRulesAsync() => + await context.RegionalTaxRules.ToListAsync(); + + public async Task 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 GetUserRoleLevelAsync(Guid userId) => await context.Users .Where(u => u.Id == userId && u.IsActive) diff --git a/Distribution/Features/Region/RegionService.cs b/Distribution/Features/Region/RegionService.cs index 8a88406..e04cc2d 100644 --- a/Distribution/Features/Region/RegionService.cs +++ b/Distribution/Features/Region/RegionService.cs @@ -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 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 CanManageRegionAsync(Guid regionId) => Task.FromResult(true); public async Task> GetAllAsync() => (await repo.GetAllAsync()) @@ -82,26 +78,97 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser return true; } - public async Task 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 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 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 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 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 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> 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(); + + 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 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; + } } } diff --git a/Distribution/docs/10_region_management.md b/Distribution/docs/10_region_management.md index 4137265..cd80594 100644 --- a/Distribution/docs/10_region_management.md +++ b/Distribution/docs/10_region_management.md @@ -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. --- diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 2934e21..3d8e6fa 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -117,10 +117,10 @@ here — see `SRS_User_Management_Module.pdf`. |---|---|---| | `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. | | `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. | -| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. | -| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. | -| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. | -| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). | +| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/manage` (consolidated with the three rows below), backed by the `RegionalTarget` entity. Role-level restriction (`RegionalManager`+ with region scope, or `RegionalAgency` across any region) is temporarily disabled — see `10_region_management.md`. | +| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalBudget` entity. Same endpoint/access note as `setRegionalTarget`. | +| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role (this check remains active even with the role gate disabled). | +| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). | | `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. | | `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. | diff --git a/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-entry-sheet.tsx b/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-entry-sheet.tsx new file mode 100644 index 0000000..ec1090c --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-entry-sheet.tsx @@ -0,0 +1,265 @@ +'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 + +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([]) + const [agencyUsers, setAgencyUsers] = useState([]) + const isEdit = !!initial + + const form = useForm({ + 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 ( + !v && onClose()}> + + + {isEdit ? 'Edit Manage Entry' : 'Add Manage Entry'} + + +
+ + ( + + )} + /> + + ( + + )} + /> + + + + ( + + )} + /> + + ( + + )} + /> + + + + ( + + )} + /> + + + + ( + + )} + /> + {agencyUsers.length === 0 && ( +

No users with the RegionalAgency role found.

+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-list-client.tsx b/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-list-client.tsx new file mode 100644 index 0000000..b527091 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/region-manage/_components/region-manage-list-client.tsx @@ -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() + 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([]) + const [loading, setLoading] = useState(true) + const [agencyUsers, setAgencyUsers] = useState([]) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editingInitial, setEditingInitial] = useState(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>(() => ({ + 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 ( + <> +
+
+

Region Manage

+

+ Targets, budgets, tax rules, and agency mappings across regions +

+
+ +
+ + + + setSheetOpen(false)} + onSuccess={fetchData} + initial={editingInitial} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/network/region-manage/page.tsx b/distribution_fe/app/(dashboard)/network/region-manage/page.tsx new file mode 100644 index 0000000..16cdc8e --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/region-manage/page.tsx @@ -0,0 +1,5 @@ +import { RegionManageListClient } from './_components/region-manage-list-client' + +export default function RegionManagePage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx deleted file mode 100644 index 8d19d60..0000000 --- a/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx +++ /dev/null @@ -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([]) - const [dashboard, setDashboard] = useState(null) - const [hierarchy, setHierarchy] = useState(null) - const [saving, setSaving] = useState(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 ( - !v && onClose()}> - - - Manage {region.name} - - Targets, budgets, tax rules, agency mapping, and dashboard for this region. - - - -
-
- - setPeriod(e.target.value)} - className="w-40" - /> -
- - - -
- -
- setTargetValue(e.target.value)} - /> - -
- {dashboard?.targetValue != null && ( -

Current: {dashboard.targetValue}

- )} -
- -
- -
- setBudgetAmount(e.target.value)} - /> - -
- {dashboard?.budgetAmount != null && ( -

Current: {dashboard.budgetAmount}

- )} -
- - - -
- -
- setRuleConfig(e.target.value)} - /> - -
-
- - - -
- -
- - -
- {agencyUsers.length === 0 && ( -

No users with the RegionalAgency role found.

- )} -
- - - -
- - {hierarchy && hierarchy.routes.length > 0 ? ( -
    - {hierarchy.routes.map((r) => ( -
  • - {r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'} -
  • - ))} -
- ) : ( -

No routes under this region yet.

- )} -
- - - -
- - {dashboard ? ( -
-
Orders: {dashboard.orderCount}
-
Actual Sales: {dashboard.actualSales}
-
Target: {dashboard.targetValue ?? '—'}
-
Budget: {dashboard.budgetAmount ?? '—'}
-
- ) : ( -

Loading dashboard…

- )} -
-
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx index 2f23e80..3017e36 100644 --- a/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx +++ b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx @@ -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([]) @@ -25,9 +24,6 @@ export function RegionsClient() { const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) - const [manageOpen, setManageOpen] = useState(false) - const [managing, setManaging] = useState(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)} /> - - setManageOpen(false)} - region={managing} - /> ) } diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index bbdd53a..24e8398 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -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' }, diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index ce18073..69c7df2 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -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(`/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(`/regions/${id}/manage`, body).then(r => r.data), - setBudget: (id: string, period: string, budgetAmount: number) => - api.put(`/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('/regions/manage-entries').then(r => r.data), - mapAgency: (id: string, agencyUserId: string) => - api.post(`/regions/${id}/agencies`, { agencyUserId }).then(r => r.data), - - setTaxRule: (id: string, ruleConfig: string) => - api.put(`/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(`/regions/${id}/hierarchy`).then(r => r.data), diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index b12bf4b..f2af02a 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -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 ─────────────────────────────────────────────────── diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md index f605908..9bdd352 100644 --- a/sharedDocs/BACKEND_API_REFERENCE.md +++ b/sharedDocs/BACKEND_API_REFERENCE.md @@ -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.) ---