Assign a region manager for the route
This commit is contained in:
@@ -313,4 +313,93 @@ public class RegionServiceTests
|
|||||||
|
|
||||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Region manager
|
||||||
|
|
||||||
|
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
|
||||||
|
Role = new Role { Name = role, Level = 50 }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
|
||||||
|
|
||||||
|
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser("SalesRep");
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
|
||||||
|
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
|
||||||
|
|
||||||
|
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||||
|
|
||||||
|
Assert.Equal(user.Id, result!.UserId);
|
||||||
|
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
|
||||||
|
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
|
||||||
|
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
|
||||||
|
|
||||||
|
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
|
||||||
|
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
|
||||||
|
|
||||||
|
Assert.False(await _sut.RemoveManagerAsync(region.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetManagerAsync_ReturnsManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
|
||||||
|
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
|
||||||
|
|
||||||
|
var result = await _sut.GetManagerAsync(region.Id);
|
||||||
|
|
||||||
|
Assert.Equal("mgr", result!.Username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { RegionResponse, RegionManagerResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const MANAGER_ROLE = 'RegionalManager'
|
||||||
|
|
||||||
|
interface RegionManagerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
region: RegionResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionManagerDialog({ open, onClose, region }: RegionManagerDialogProps) {
|
||||||
|
const [current, setCurrent] = useState<RegionManagerResponse | null>(null)
|
||||||
|
const [candidates, setCandidates] = useState<UserResponse[]>([])
|
||||||
|
const [selected, setSelected] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setSelected('')
|
||||||
|
setCurrent(null)
|
||||||
|
Promise.all([
|
||||||
|
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
||||||
|
usersApi.list({ pageSize: 500 }),
|
||||||
|
])
|
||||||
|
.then(([manager, userRes]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCurrent(manager)
|
||||||
|
setSelected(manager?.userId ?? '')
|
||||||
|
setCandidates(getPaginatedItems(userRes).filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => !cancelled && setLoading(false))
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [open, region])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!region || !selected) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await regionsApi.setManager(region.id, selected)
|
||||||
|
toast.add({ title: 'Region manager assigned', type: 'success' })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to assign manager', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!region) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await regionsApi.removeManager(region.id)
|
||||||
|
toast.add({ title: 'Region manager removed', type: 'success' })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to remove manager', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && !saving && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Region Manager</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{region ? `${region.code} – ${region.name}. ` : ''}
|
||||||
|
Each region has one manager, chosen from users with the RegionalManager role.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current manager:{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{loading ? 'Loading…' : current ? `${current.fullName} (${current.username})` : 'None'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Select value={selected} onValueChange={(v) => setSelected(v ?? '')} disabled={loading || saving}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={candidates.length ? 'Select a manager' : 'No RegionalManager users available'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidates.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.fullName} ({u.username})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{current && (
|
||||||
|
<Button type="button" variant="destructive" onClick={remove} disabled={saving}>
|
||||||
|
Remove manager
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={save} disabled={saving || loading || !selected || selected === current?.userId}>
|
||||||
|
{saving ? 'Saving…' : 'Assign manager'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
|
|||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RegionSheet } from './region-sheet'
|
import { RegionSheet } from './region-sheet'
|
||||||
|
import { RegionManagerDialog } from './region-manager-dialog'
|
||||||
|
|
||||||
export function RegionsClient() {
|
export function RegionsClient() {
|
||||||
const [data, setData] = useState<RegionResponse[]>([])
|
const [data, setData] = useState<RegionResponse[]>([])
|
||||||
@@ -27,6 +28,7 @@ export function RegionsClient() {
|
|||||||
const [viewOnly, setViewOnly] = useState(false)
|
const [viewOnly, setViewOnly] = useState(false)
|
||||||
const [deleting, setDeleting] = useState<RegionResponse | null>(null)
|
const [deleting, setDeleting] = useState<RegionResponse | null>(null)
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false)
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||||
|
const [managerRegion, setManagerRegion] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -124,6 +126,11 @@ export function RegionsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'confirm',
|
||||||
|
label: 'Manage region manager',
|
||||||
|
onClick: (region: RegionResponse) => setManagerRegion(region),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'view',
|
action: 'view',
|
||||||
label: 'View region',
|
label: 'View region',
|
||||||
@@ -176,6 +183,12 @@ export function RegionsClient() {
|
|||||||
readOnly={viewOnly}
|
readOnly={viewOnly}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
<RegionManagerDialog
|
||||||
|
open={!!managerRegion}
|
||||||
|
onClose={() => setManagerRegion(null)}
|
||||||
|
region={managerRegion}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={!!deleting} onOpenChange={(v) => !v && !deleteBusy && setDeleting(null)}>
|
<Dialog open={!!deleting} onOpenChange={(v) => !v && !deleteBusy && setDeleting(null)}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from '@/lib/api'
|
|||||||
import type {
|
import type {
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
RegionResponse,
|
RegionResponse,
|
||||||
|
RegionManagerResponse,
|
||||||
ManageRegionResponse,
|
ManageRegionResponse,
|
||||||
RegionHierarchyResponse,
|
RegionHierarchyResponse,
|
||||||
RegionDashboardResponse,
|
RegionDashboardResponse,
|
||||||
@@ -92,6 +93,15 @@ export const regions = {
|
|||||||
list: (params?: PageParams) =>
|
list: (params?: PageParams) =>
|
||||||
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
getManager: (id: string) =>
|
||||||
|
api.get<RegionManagerResponse>(`/regions/${id}/manager`).then(r => r.data),
|
||||||
|
|
||||||
|
setManager: (id: string, userId: string) =>
|
||||||
|
api.put<RegionManagerResponse>(`/regions/${id}/manager`, { userId }).then(r => r.data),
|
||||||
|
|
||||||
|
removeManager: (id: string) =>
|
||||||
|
api.delete(`/regions/${id}/manager`),
|
||||||
|
|
||||||
nextCode: () =>
|
nextCode: () =>
|
||||||
api.get<{ code: string }>('/regions/next-code').then(r => r.data),
|
api.get<{ code: string }>('/regions/next-code').then(r => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -729,3 +729,10 @@ export interface SysConfigResponse {
|
|||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionManagerResponse {
|
||||||
|
regionId: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user