From b487d470559bb1dc4439c9bee045db1d3b66d2b0 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Tue, 26 May 2026 12:50:40 +0300 Subject: [PATCH] feat(freight:backoffice): migrated smart-office user-management --- apps/edr-freight-api/src/app.module.ts | 2 + .../backoffice/backoffice.controller.ts | 41 + .../modules/backoffice/backoffice.module.ts | 17 + .../modules/backoffice/backoffice.service.ts | 127 + .../dto/update-employee-user-roles.dto.ts | 9 + apps/edr-freight-web/backoffice/src/App.tsx | 17 +- .../backoffice/src/auth/AuthProvider.tsx | 9 +- .../backoffice/src/auth/types.ts | 44 +- .../user-management/UserManagementPage.tsx | 2099 +++++++++++++++++ 9 files changed, 2350 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts create mode 100644 apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts create mode 100644 apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts create mode 100644 apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 141c2b181..20961c3c6 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; @Module({ @@ -40,6 +41,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; NotificationsModule, FileUploadSettingsModule, DropdownSettingsModule, + BackofficeModule, ], providers: [EdrOrgSeeder], }) diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts new file mode 100644 index 000000000..3459de0f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -0,0 +1,41 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BackofficeService } from "./backoffice.service"; +import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; + +@ApiTags("backoffice") +@Controller("backoffice") +export class BackofficeController { + constructor(private readonly backofficeService: BackofficeService) {} + + @Get("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) + getEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + ) { + return this.backofficeService.getEmployeeUserRoles(organizationId, userId); + } + + @Put("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" }) + replaceEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + @Body() dto: UpdateEmployeeUserRolesDto, + ) { + return this.backofficeService.replaceEmployeeUserRoles( + organizationId, + userId, + dto.roleIds, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts new file mode 100644 index 000000000..18b4e6947 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeController } from "./backoffice.controller"; +import { BackofficeService } from "./backoffice.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([Role, UserRole, User])], + controllers: [BackofficeController], + providers: [BackofficeService], + exports: [BackofficeService], +}) +export class BackofficeModule {} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts new file mode 100644 index 000000000..b5206be7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -0,0 +1,127 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { DataSource, In, IsNull, Repository } from "typeorm"; + +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +const RESERVED_ROLE_KEYS = new Set([ + "super_admin", + "organization_admin", + "unit_admin", +]); + +@Injectable() +export class BackofficeService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(UserRole) + private readonly userRoleRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly dataSource: DataSource, + ) {} + + async getEmployeeUserRoles(organizationId: string, userId: string) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const userRoles = await this.userRoleRepository.find({ + where: { + userId, + organizationId, + unitId: IsNull(), + }, + relations: { + role: true, + }, + order: { + role: { + key: "ASC", + }, + }, + }); + + return userRoles + .map((userRole) => userRole.role) + .filter((role): role is Role => Boolean(role)) + .map((role) => ({ + id: role.id, + key: role.key, + name: role.name, + })); + } + + async replaceEmployeeUserRoles( + organizationId: string, + userId: string, + roleIds: string[], + ) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const uniqueRoleIds = [...new Set(roleIds)]; + const roles = uniqueRoleIds.length + ? await this.roleRepository.find({ + where: { + id: In(uniqueRoleIds), + }, + }) + : []; + + if (roles.length !== uniqueRoleIds.length) { + throw new NotFoundException("one_or_more_roles_not_found"); + } + + const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key)); + if (reservedRoles.length) { + throw new BadRequestException("reserved_roles_must_use_admin_actions"); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(UserRole).delete({ + userId, + organizationId, + unitId: IsNull(), + }); + + if (!roles.length) { + return; + } + + await manager.getRepository(UserRole).insert( + roles.map((role) => ({ + userId, + roleId: role.id, + organizationId, + })), + ); + }); + + return this.getEmployeeUserRoles(organizationId, userId); + } + + private async assertUserBelongsToOrganization( + organizationId: string, + userId: string, + ) { + const exists = await this.userRepository + .createQueryBuilder("user") + .innerJoin( + "user.employee", + "employee", + "employee.organizationId = :organizationId AND employee.isCurrent = true", + { organizationId }, + ) + .where("user.id = :userId", { userId }) + .getExists(); + + if (!exists) { + throw new NotFoundException("user_not_found_in_organization"); + } + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts new file mode 100644 index 000000000..f216939b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsUUID } from "class-validator"; + +export class UpdateEmployeeUserRolesDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID("4", { each: true }) + roleIds!: string[]; +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6d2cfa4c0..6d28d8ac9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,14 +1,11 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; -import { LayoutDashboard, ShieldCheck, Users, Network } from "lucide-react"; +import { LayoutDashboard, Network } from "lucide-react"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; import OverviewPage from "./pages/dashboard/OverviewPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage"; -import OrgStructurePage from "./pages/dashboard/org-structure/OrgStructurePage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import LoadingScreen from "./components/LoadingScreen"; const sidebarItems: SidebarItem[] = [ @@ -18,8 +15,8 @@ const sidebarItems: SidebarItem[] = [ icon: , }, { - label: "Org structure", - href: "/dashboard/org-structure", + label: "User management", + href: "/dashboard/user-management", icon: , }, ]; @@ -69,9 +66,9 @@ const App = () => { } /> }> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index d49a0e9d9..66834c9f5 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -125,8 +125,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { mfaEmailRef.current = null; }, logout: () => { + const preservedTheme = window.localStorage.getItem("edr-theme"); + clearSessionCookies(); - localStorage.clear(); + window.localStorage.clear(); + + if (preservedTheme === "dark" || preservedTheme === "light") { + window.localStorage.setItem("edr-theme", preservedTheme); + } + setUser(null); window.location.replace("/auth"); }, diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 736fd9a36..2d4ecd536 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -1,11 +1,47 @@ +interface LocaleText { + en?: string; + am?: string; +} + +interface AuthRole { + id?: string; + key?: string; +} + +interface AuthPermission { + id?: string; + key?: string; +} + +interface AuthEmployeePosition { + id?: string; + employeePositionId?: string; + name?: LocaleText; + key?: string; + isDelegate?: boolean; + parentPositionId?: string | null; + permissions?: AuthPermission[]; +} + +interface AuthEmployeeRecord { + id?: string; + organizationId?: string; + unitId?: string; + name?: LocaleText; + positions?: AuthEmployeePosition[]; +} + export interface AuthUser { id?: string; email?: string; username?: string; - name?: { - en?: string; - am?: string; - }; + phoneNumber?: string; + name?: LocaleText; + roles?: AuthRole[]; + permissions?: AuthPermission[]; + employee?: AuthEmployeeRecord[]; + hasSetPassword?: boolean; + status?: string; } export interface AuthTokens { diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx new file mode 100644 index 000000000..c1683aa11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -0,0 +1,2099 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { isAxiosError } from "axios"; +import { + Badge, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@edr/ui-common"; +import { + Building2, + ChevronRight, + FolderTree, + MoreHorizontal, + Network, + RefreshCw, + Search, + ShieldCheck, + UserCheck, + UserMinus, + Users, +} from "lucide-react"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface OrganizationRecord { + id: string; + key: string; + name: LocaleText; +} + +interface UnitRecord { + id: string; + key: string; + name: LocaleText; + parentUnitId?: string | null; +} + +interface PositionRecord { + id: string; + key: string; + name: LocaleText; + parentPositionId?: string | null; + rank?: number; +} + +interface EmployeeUserRecord { + id: string; + name?: LocaleText; + email?: string; + phoneNumber?: string; + username?: string; +} + +interface EmployeePositionSummary { + id: string; + position?: { + id: string; + name?: LocaleText; + }; +} + +interface EmployeeRecord { + id: string; + name?: LocaleText; + user?: EmployeeUserRecord; + status?: string; + employeePositions?: EmployeePositionSummary[]; +} + +interface RoleRecord { + id: string; + key: string; + name: LocaleText; +} + +interface PositionTreeNode extends PositionRecord { + children: PositionTreeNode[]; +} + +interface OrganizationConfigurationRecord { + id: string; + organizationId: string; + maximumNumberOfUnits?: number | null; + canCreateBranchByItself?: boolean; + canStartReceivingRecord?: boolean; +} + +interface PositionFormState { + id?: string; + nameEn: string; + nameAm: string; + key: string; + parentPositionId: string; +} + +interface UnitFormState { + nameEn: string; + nameAm: string; + key: string; +} + +interface EmployeeInviteFormState { + nameEn: string; + nameAm: string; + email: string; + username: string; + phoneNumber: string; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]); + +const emptyInviteForm: EmployeeInviteFormState = { + nameEn: "", + nameAm: "", + email: "", + username: "", + phoneNumber: "", +}; + +const emptyUnitForm: UnitFormState = { + nameEn: "", + nameAm: "", + key: "", +}; + +const emptyPositionForm: PositionFormState = { + nameEn: "", + nameAm: "", + key: "", + parentPositionId: "", +}; + +const inputClassName = + "w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950"; +const buttonClassName = + "inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60"; + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => { + if (!value) { + return fallback; + } + + return value.en ?? value.am ?? fallback; +}; + +const getItems = (payload: ListResponse | T[] | undefined | null) => { + if (!payload) { + return [] as T[]; + } + + if (Array.isArray(payload)) { + return payload; + } + + return payload.items ?? payload.data ?? []; +}; + +const toInternalKey = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/_+/g, "_"); + +const buildTree = (positions: PositionRecord[]) => { + const nodes = new Map(); + + positions.forEach((position) => { + nodes.set(position.id, { ...position, children: [] }); + }); + + const roots: PositionTreeNode[] = []; + + nodes.forEach((node) => { + if (node.parentPositionId) { + const parent = nodes.get(node.parentPositionId); + + if (parent) { + parent.children.push(node); + return; + } + } + + roots.push(node); + }); + + const sortNodes = (items: PositionTreeNode[]) => { + items.sort((left, right) => { + const rankDiff = (left.rank ?? 0) - (right.rank ?? 0); + if (rankDiff !== 0) { + return rankDiff; + } + + return getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ); + }); + + items.forEach((item) => sortNodes(item.children)); + }; + + sortNodes(roots); + + return roots; +}; + +const getErrorMessage = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (typeof message === "string") { + return message; + } + if (Array.isArray(message) && typeof message[0] === "string") { + return message[0]; + } + } + + return error instanceof Error ? error.message : fallback; +}; + +const Field = ({ label, children }: { label: string; children: ReactNode }) => ( + +); + +const ManagementDialog = ({ + open, + title, + description, + onOpenChange, + children, +}: { + open: boolean; + title: string; + description?: string; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) => ( + + + + {title} + {description ? {description} : null} + + {children} + + +); + +const TreeNode = ({ + node, + depth = 0, + selectedPositionId, + onSelect, + expandedDepartmentIds, + onToggleExpand, + onInviteUser, + onAssignUser, + onAddSubDepartment, + onEdit, + onDelete, + disabled, +}: { + node: PositionTreeNode; + depth?: number; + selectedPositionId: string | null; + onSelect: (node: PositionRecord) => void; + expandedDepartmentIds: Set; + onToggleExpand: (nodeId: string) => void; + onInviteUser: (node: PositionRecord) => void; + onAssignUser: (node: PositionRecord) => void; + onAddSubDepartment: (node: PositionRecord) => void; + onEdit: (node: PositionRecord) => void; + onDelete: (node: PositionRecord) => void; + disabled?: boolean; +}) => { + const hasChildren = node.children.length > 0; + const isSelected = selectedPositionId === node.id; + const isExpanded = expandedDepartmentIds.has(node.id); + + return ( +
  • +
    + {hasChildren ? ( + + ) : ( +
    + + {hasChildren && isExpanded ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +}; + +const UserManagementPage = () => { + const { user } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [units, setUnits] = useState([]); + const [positions, setPositions] = useState([]); + const [orgEmployees, setOrgEmployees] = useState([]); + const [positionMembers, setPositionMembers] = useState([]); + const [selectedOrgConfiguration, setSelectedOrgConfiguration] = + useState(null); + const [expandedDepartmentIds, setExpandedDepartmentIds] = useState>(new Set()); + const [orgAdminUserIds, setOrgAdminUserIds] = useState>(new Set()); + const [unitAdminUserIds, setUnitAdminUserIds] = useState>(new Set()); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [selectedUnitId, setSelectedUnitId] = useState(null); + const [selectedPositionId, setSelectedPositionId] = useState(null); + const [loading, setLoading] = useState(true); + const [membersLoading, setMembersLoading] = useState(false); + const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + const [loadError, setLoadError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [unitSearch, setUnitSearch] = useState(""); + const [positionSearch, setPositionSearch] = useState(""); + const [employeeSearch, setEmployeeSearch] = useState(""); + const [assignSearch, setAssignSearch] = useState(""); + const [unitForm, setUnitForm] = useState(emptyUnitForm); + const [positionForm, setPositionForm] = useState(emptyPositionForm); + const [inviteForm, setInviteForm] = useState(emptyInviteForm); + const [roleIds, setRoleIds] = useState([]); + const [availableRoles, setAvailableRoles] = useState([]); + const [rolesLoading, setRolesLoading] = useState(false); + const [isCreateUnitOpen, setIsCreateUnitOpen] = useState(false); + const [isEditUnitOpen, setIsEditUnitOpen] = useState(false); + const [isCreatePositionOpen, setIsCreatePositionOpen] = useState(false); + const [isEditPositionOpen, setIsEditPositionOpen] = useState(false); + const [isInviteEmployeeOpen, setIsInviteEmployeeOpen] = useState(false); + const [isAssignEmployeeOpen, setIsAssignEmployeeOpen] = useState(false); + const [isManageRolesOpen, setIsManageRolesOpen] = useState(false); + const [selectedRoleUser, setSelectedRoleUser] = useState(null); + const [createPositionMode, setCreatePositionMode] = useState<"create-root" | "create-child">( + "create-root", + ); + + const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); + const allowedOrgIds = useMemo( + () => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)), + [user?.employee], + ); + + const visibleOrganizations = useMemo(() => { + if (isSuperAdmin) { + return organizations; + } + + return organizations.filter((organization) => allowedOrgIds.has(organization.id)); + }, [allowedOrgIds, isSuperAdmin, organizations]); + + const selectedOrganization = useMemo( + () => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null, + [selectedOrgId, visibleOrganizations], + ); + const selectedUnit = useMemo( + () => units.find((item) => item.id === selectedUnitId) ?? null, + [selectedUnitId, units], + ); + const selectedPosition = useMemo( + () => positions.find((item) => item.id === selectedPositionId) ?? null, + [positions, selectedPositionId], + ); + const positionTree = useMemo(() => buildTree(positions), [positions]); + const parentOptions = useMemo( + () => positions.filter((item) => item.id !== selectedPositionId), + [positions, selectedPositionId], + ); + const unitsAllowedLabel = + selectedOrgConfiguration?.maximumNumberOfUnits == null + ? "∞" + : `${selectedOrgConfiguration.maximumNumberOfUnits}`; + + const filteredUnits = useMemo(() => { + const query = unitSearch.trim().toLowerCase(); + if (!query) { + return units; + } + + return units.filter((item) => { + const label = getLocaleLabel(item.name, item.key).toLowerCase(); + return label.includes(query) || item.key.toLowerCase().includes(query); + }); + }, [unitSearch, units]); + + const filteredPositionTree = useMemo(() => { + const query = positionSearch.trim().toLowerCase(); + if (!query) { + return positionTree; + } + + const filterNode = (node: PositionTreeNode): PositionTreeNode | null => { + const label = `${getLocaleLabel(node.name, node.key)} ${node.key}`.toLowerCase(); + const matchingChildren = node.children + .map((child) => filterNode(child)) + .filter(Boolean) as PositionTreeNode[]; + + if (label.includes(query) || matchingChildren.length) { + return { ...node, children: matchingChildren }; + } + + return null; + }; + + return positionTree.map((node) => filterNode(node)).filter(Boolean) as PositionTreeNode[]; + }, [positionSearch, positionTree]); + + const filteredPositionMembers = useMemo(() => { + const query = employeeSearch.trim().toLowerCase(); + if (!query) { + return positionMembers; + } + + return positionMembers.filter((employee) => { + const name = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + const username = employee.user?.username?.toLowerCase() ?? ""; + return name.includes(query) || email.includes(query) || username.includes(query); + }); + }, [employeeSearch, positionMembers]); + + const assignableEmployees = useMemo(() => { + const query = assignSearch.trim().toLowerCase(); + return orgEmployees.filter((employee) => { + const label = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + if (!query) { + return true; + } + return label.includes(query) || email.includes(query); + }); + }, [assignSearch, orgEmployees]); + + const resetMessages = () => { + setActionError(null); + setActionSuccess(null); + }; + + const loadOrganizations = useCallback(async () => { + setLoading(true); + setLoadError(null); + + try { + const response = await api.get>("/organizations"); + const nextOrganizations = getItems(response.data); + setOrganizations(nextOrganizations); + } catch (error) { + setLoadError(getErrorMessage(error, "Failed to load organizations.")); + } finally { + setLoading(false); + } + }, []); + + const loadOrgEmployees = useCallback(async (organizationId: string) => { + setOrgEmployeesLoading(true); + + try { + const response = await api.get>( + `/employees/${organizationId}/by-organization`, + ); + setOrgEmployees(getItems(response.data)); + } catch { + setOrgEmployees([]); + } finally { + setOrgEmployeesLoading(false); + } + }, []); + + const loadOrgAdmins = useCallback(async (organizationId: string) => { + try { + const response = await api.get>( + `/organizations/org-admins/${organizationId}`, + ); + setOrgAdminUserIds( + new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]), + ); + } catch { + setOrgAdminUserIds(new Set()); + } + }, []); + + const loadUnitAdmins = useCallback(async (unitId: string | null) => { + if (!unitId) { + setUnitAdminUserIds(new Set()); + return; + } + + try { + const response = await api.get>(`/units/unit-admins/${unitId}`); + setUnitAdminUserIds( + new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]), + ); + } catch { + setUnitAdminUserIds(new Set()); + } + }, []); + + const loadUnits = useCallback(async (organizationId: string) => { + try { + const response = await api.get>(`/units/list/${organizationId}`); + setUnits(getItems(response.data)); + } catch (error) { + setLoadError(getErrorMessage(error, "Failed to load units.")); + setUnits([]); + } + }, []); + + const loadOrganizationConfiguration = useCallback(async (organizationId: string) => { + try { + const response = await api.get>( + `/organization-configurations/list/${organizationId}`, + ); + setSelectedOrgConfiguration(getItems(response.data)[0] ?? null); + } catch { + setSelectedOrgConfiguration(null); + } + }, []); + + const loadPositions = useCallback(async (unitId: string) => { + try { + const response = await api.get>(`/positions/list/${unitId}`); + setPositions(getItems(response.data)); + } catch (error) { + setLoadError(getErrorMessage(error, "Failed to load positions.")); + setPositions([]); + } + }, []); + + const loadPositionMembers = useCallback(async (positionId: string | null) => { + if (!positionId) { + setPositionMembers([]); + return; + } + + setMembersLoading(true); + + try { + const response = await api.get>( + `/positions/current/${positionId}/employees`, + ); + setPositionMembers(getItems(response.data)); + } catch { + setPositionMembers([]); + } finally { + setMembersLoading(false); + } + }, []); + + const refreshOrganizations = useCallback(async () => { + await loadOrganizations(); + }, [loadOrganizations]); + + const refreshSelectedOrg = useCallback(async () => { + if (!selectedOrgId) { + setUnits([]); + setPositions([]); + setPositionMembers([]); + setOrgEmployees([]); + setSelectedOrgConfiguration(null); + return; + } + + await Promise.all([ + loadUnits(selectedOrgId), + loadOrgEmployees(selectedOrgId), + loadOrgAdmins(selectedOrgId), + loadOrganizationConfiguration(selectedOrgId), + ]); + }, [loadOrgAdmins, loadOrgEmployees, loadOrganizationConfiguration, loadUnits, selectedOrgId]); + + const refreshSelectedUnit = useCallback(async () => { + if (!selectedUnitId) { + setPositions([]); + setPositionMembers([]); + setUnitAdminUserIds(new Set()); + return; + } + + await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]); + }, [loadPositions, loadUnitAdmins, selectedUnitId]); + + useEffect(() => { + void loadOrganizations(); + }, [loadOrganizations]); + + useEffect(() => { + if (!visibleOrganizations.length) { + setSelectedOrgId(null); + setSelectedOrgConfiguration(null); + setUnits([]); + setPositions([]); + setPositionMembers([]); + setOrgEmployees([]); + return; + } + + if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { + return; + } + + setSelectedOrgId(null); + setSelectedOrgConfiguration(null); + setUnits([]); + setPositions([]); + setPositionMembers([]); + setOrgEmployees([]); + }, [selectedOrgId, visibleOrganizations]); + + useEffect(() => { + if (!selectedOrgId) { + setUnits([]); + setPositions([]); + setSelectedUnitId(null); + setSelectedPositionId(null); + setExpandedDepartmentIds(new Set()); + setPositionMembers([]); + setOrgEmployees([]); + setOrgAdminUserIds(new Set()); + setSelectedOrgConfiguration(null); + return; + } + }, [selectedOrgId]); + + useEffect(() => { + if (!selectedOrgId || !selectedUnitId) { + return; + } + + if (!units.some((unit) => unit.id === selectedUnitId)) { + setSelectedUnitId(null); + } + }, [selectedOrgId, selectedUnitId, units]); + + useEffect(() => { + if (!selectedUnitId) { + setPositions([]); + setSelectedPositionId(null); + setExpandedDepartmentIds(new Set()); + setPositionMembers([]); + setUnitAdminUserIds(new Set()); + return; + } + }, [selectedUnitId]); + + useEffect(() => { + if (!selectedUnitId || !selectedPositionId) { + return; + } + + if (!positions.some((position) => position.id === selectedPositionId)) { + setSelectedPositionId(null); + } + }, [positions, selectedPositionId, selectedUnitId]); + + useEffect(() => { + if (!selectedPositionId) { + setPositionMembers([]); + return; + } + }, [selectedPositionId]); + + useEffect(() => { + void loadPositionMembers(selectedPositionId); + }, [loadPositionMembers, selectedPositionId]); + + const handleRefresh = async () => { + resetMessages(); + await Promise.all([ + refreshOrganizations(), + selectedOrgId ? refreshSelectedOrg() : Promise.resolve(), + selectedUnitId ? refreshSelectedUnit() : Promise.resolve(), + loadPositionMembers(selectedPositionId), + ]); + }; + + const handleSelectOrganization = async (organizationId: string) => { + setSelectedOrgId(organizationId); + setSelectedUnitId(null); + setSelectedPositionId(null); + setExpandedDepartmentIds(new Set()); + setUnits([]); + setPositions([]); + setPositionMembers([]); + setOrgEmployees([]); + setOrgAdminUserIds(new Set()); + setUnitAdminUserIds(new Set()); + setSelectedOrgConfiguration(null); + resetMessages(); + + try { + await Promise.all([ + loadUnits(organizationId), + loadOrgEmployees(organizationId), + loadOrgAdmins(organizationId), + loadOrganizationConfiguration(organizationId), + ]); + } catch { + // Individual loaders already handle their own error state. + } + }; + + const handleSelectUnit = async (unitId: string) => { + setSelectedUnitId(unitId); + setSelectedPositionId(null); + setExpandedDepartmentIds(new Set()); + setPositions([]); + setPositionMembers([]); + setUnitAdminUserIds(new Set()); + resetMessages(); + + try { + await Promise.all([loadPositions(unitId), loadUnitAdmins(unitId)]); + } catch { + // Individual loaders already handle their own error state. + } + }; + + const handleToggleDepartment = (departmentId: string) => { + setExpandedDepartmentIds((current) => { + const next = new Set(current); + if (next.has(departmentId)) { + next.delete(departmentId); + } else { + next.add(departmentId); + } + return next; + }); + }; + + const openCreateSubDepartmentDialog = (parent: PositionRecord) => { + setSelectedPositionId(parent.id); + setExpandedDepartmentIds((current) => new Set(current).add(parent.id)); + setCreatePositionMode("create-child"); + setPositionForm({ + ...emptyPositionForm, + parentPositionId: parent.id, + }); + resetMessages(); + setIsCreatePositionOpen(true); + }; + + const openInviteForDepartment = (position: PositionRecord) => { + setSelectedPositionId(position.id); + resetMessages(); + setIsInviteEmployeeOpen(true); + }; + + const openAssignForDepartment = (position: PositionRecord) => { + setSelectedPositionId(position.id); + resetMessages(); + setIsAssignEmployeeOpen(true); + }; + + const openCreateUnitDialog = () => { + setUnitForm(emptyUnitForm); + resetMessages(); + setIsCreateUnitOpen(true); + }; + + const openEditUnitDialog = (unit: UnitRecord) => { + setUnitForm({ + nameEn: unit.name.en ?? "", + nameAm: unit.name.am ?? "", + key: unit.key, + }); + resetMessages(); + setIsEditUnitOpen(true); + }; + + const openCreatePositionDialog = (mode: "create-root" | "create-child") => { + setCreatePositionMode(mode); + setPositionForm({ + ...emptyPositionForm, + parentPositionId: mode === "create-child" && selectedPosition ? selectedPosition.id : "", + }); + resetMessages(); + setIsCreatePositionOpen(true); + }; + + const openEditPositionDialog = (position: PositionRecord) => { + setPositionForm({ + id: position.id, + nameEn: position.name.en ?? "", + nameAm: position.name.am ?? "", + key: position.key, + parentPositionId: position.parentPositionId ?? "", + }); + resetMessages(); + setIsEditPositionOpen(true); + }; + + const handleCreateUnit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedOrganization) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.post("/units", { + name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() }, + key: unitForm.key.trim(), + organizationId: selectedOrganization.id, + }); + setActionSuccess("Unit created."); + setIsCreateUnitOpen(false); + await refreshSelectedOrg(); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to create unit.")); + } finally { + setSubmitting(false); + } + }; + + const handleUpdateUnit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedOrganization || !selectedUnit) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.put(`/units/${selectedUnit.id}`, { + name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() }, + key: unitForm.key.trim(), + organizationId: selectedOrganization.id, + }); + setActionSuccess("Unit updated."); + setIsEditUnitOpen(false); + await refreshSelectedOrg(); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to update unit.")); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteUnit = async (unit: UnitRecord) => { + if (!unit) { + return; + } + + if (!window.confirm(`Delete unit '${getLocaleLabel(unit.name, unit.key)}'?`)) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.delete(`/units/${unit.id}`); + setActionSuccess("Unit deleted."); + await refreshSelectedOrg(); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to delete unit.")); + } finally { + setSubmitting(false); + } + }; + + const handleSavePosition = async (event: React.FormEvent, isEditing: boolean) => { + event.preventDefault(); + if (!selectedOrganization || !selectedUnit) { + setActionError("Select a unit before managing positions."); + return; + } + + setSubmitting(true); + resetMessages(); + + try { + const payload = { + id: positionForm.id, + name: { am: positionForm.nameAm.trim(), en: positionForm.nameEn.trim() }, + key: positionForm.key.trim(), + unitId: selectedUnit.id, + organizationId: selectedOrganization.id, + parentPositionId: positionForm.parentPositionId || null, + rank: 0, + }; + + if (isEditing && positionForm.id) { + const existing = positions.find((item) => item.id === positionForm.id); + await api.put(`/positions/${positionForm.id}`, payload); + + if ((existing?.parentPositionId ?? "") !== (positionForm.parentPositionId || "")) { + await api.patch("/positions/change-parent", { + positionId: positionForm.id, + newParentId: positionForm.parentPositionId || null, + }); + } + + setActionSuccess("Position updated."); + setIsEditPositionOpen(false); + } else { + await api.post("/positions", payload); + setActionSuccess("Position created."); + setIsCreatePositionOpen(false); + } + + await refreshSelectedUnit(); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to save position.")); + } finally { + setSubmitting(false); + } + }; + + const handleDeletePosition = async (position: PositionRecord) => { + if (!position) { + return; + } + + if (!window.confirm(`Delete position '${getLocaleLabel(position.name, position.key)}'?`)) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.delete(`/positions/${position.id}`); + setActionSuccess("Position deleted."); + await refreshSelectedUnit(); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to delete position.")); + } finally { + setSubmitting(false); + } + }; + + const handleInviteEmployee = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedPosition) { + setActionError("Select a position before inviting an employee."); + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.post("/employee-positions/invite", { + positionId: selectedPosition.id, + username: inviteForm.username.trim(), + phoneNumber: inviteForm.phoneNumber.trim(), + email: inviteForm.email.trim(), + name: { am: inviteForm.nameAm.trim(), en: inviteForm.nameEn.trim() }, + }); + setInviteForm(emptyInviteForm); + setActionSuccess("Employee invited and assigned to the selected position."); + setIsInviteEmployeeOpen(false); + await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to invite employee.")); + } finally { + setSubmitting(false); + } + }; + + const handleAssignEmployee = async (employeeId: string) => { + if (!selectedPosition) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.post("/employee-positions/assign", { + positionId: selectedPosition.id, + employeeId, + }); + setActionSuccess("Employee assigned to the selected position."); + setIsAssignEmployeeOpen(false); + setAssignSearch(""); + await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to assign employee.")); + } finally { + setSubmitting(false); + } + }; + + const handleToggleUserActivation = async (employee: EmployeeRecord) => { + if (!employee.user?.id) { + return; + } + + const isInactive = employee.status?.toLowerCase() === "inactive"; + setSubmitting(true); + resetMessages(); + + try { + await api.patch( + `/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`, + ); + setActionSuccess(isInactive ? "User activated." : "User deactivated."); + await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPositionId)]); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to update user status.")); + } finally { + setSubmitting(false); + } + }; + + const handleAdminRoleMutation = async ( + endpoint: string, + payload: { organizationId?: string; unitId?: string; userId: string }, + successMessage: string, + ) => { + setSubmitting(true); + resetMessages(); + + try { + await api.post(endpoint, payload); + setActionSuccess(successMessage); + await Promise.all([ + selectedOrgId ? loadOrgAdmins(selectedOrgId) : Promise.resolve(), + loadUnitAdmins(selectedUnitId), + ]); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to update admin role.")); + } finally { + setSubmitting(false); + } + }; + + const openManageRolesDialog = async (employee: EmployeeRecord) => { + if (!selectedOrgId || !employee.user?.id) { + return; + } + + setRolesLoading(true); + resetMessages(); + setSelectedRoleUser(employee); + setIsManageRolesOpen(true); + + try { + const [rolesResponse, assignedResponse] = await Promise.all([ + api.get>("/roles"), + api.get(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`), + ]); + const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key)); + setAvailableRoles(roles); + setRoleIds(getItems(assignedResponse.data).map((role) => role.id)); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to load user roles.")); + setAvailableRoles([]); + setRoleIds([]); + } finally { + setRolesLoading(false); + } + }; + + const handleSaveRoles = async () => { + if (!selectedOrgId || !selectedRoleUser?.user?.id) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.put( + `/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`, + { roleIds }, + ); + setActionSuccess("User roles updated."); + setIsManageRolesOpen(false); + } catch (error) { + setActionError(getErrorMessage(error, "Failed to update user roles.")); + } finally { + setSubmitting(false); + } + }; + + return ( +
    +
    +
    +
    +
    + +
    +
    +

    + User management +

    +

    Organization workspace

    +

    + Manage organizations, branches, departments, team members, and admin access from a + single SmartOffice-style workspace. +

    +
    +
    + + +
    +
    + + {actionSuccess ? ( +
    + {actionSuccess} +
    + ) : null} + + {actionError ? ( +
    + {actionError} +
    + ) : null} + + {loadError ? ( +
    + {loadError} +
    + ) : null} + + {loading ? ( +
    + Loading user management workspace... +
    + ) : ( +
    + + + + + + +
    +
    +
    +

    Team members

    +

    + {selectedPosition + ? `${getLocaleLabel(selectedPosition.name, selectedPosition.key)} in ${getLocaleLabel(selectedUnit?.name, selectedUnit?.key ?? "")}` + : "Select a department"} +

    +
    +
    + +
    + + setEmployeeSearch(event.target.value)} + placeholder="Search team members" + /> +
    + + {membersLoading ? ( +
    + Loading team members... +
    + ) : filteredPositionMembers.length ? ( +
    + {filteredPositionMembers.map((employee) => { + const userId = employee.user?.id; + const isOrgAdmin = Boolean(userId && orgAdminUserIds.has(userId)); + const isUnitAdmin = Boolean(userId && unitAdminUserIds.has(userId)); + const displayName = getLocaleLabel( + employee.name ?? employee.user?.name, + employee.user?.email ?? employee.id, + ); + const assignedPositions = employee.employeePositions + ?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? "")) + .filter(Boolean) + .join(", "); + + return ( +
    +
    +
    +
    +

    {displayName}

    + {isOrgAdmin ? ( + Org admin + ) : null} + {isUnitAdmin ? ( + Unit admin + ) : null} + {employee.status ? ( + {employee.status} + ) : null} +
    +
    + {employee.user?.email || employee.user?.username || "No contact info"} +
    + {assignedPositions ? ( +
    + Current positions: {assignedPositions} +
    + ) : null} +
    + +
    + + + + + + + +
    +
    +
    + ); + })} +
    + ) : ( +
    + No team members found for this department. +
    + )} +
    +
    + )} + + +
    + + setUnitForm((current) => ({ ...current, nameEn: event.target.value }))} + /> + + + setUnitForm((current) => ({ ...current, nameAm: event.target.value }))} + /> + + +
    + setUnitForm((current) => ({ ...current, key: event.target.value }))} + /> + +
    +
    +
    + + +
    +
    +
    + + +
    + + setUnitForm((current) => ({ ...current, nameEn: event.target.value }))} + /> + + + setUnitForm((current) => ({ ...current, nameAm: event.target.value }))} + /> + + + setUnitForm((current) => ({ ...current, key: event.target.value }))} + /> + +
    + + +
    +
    +
    + + +
    void handleSavePosition(event, false)}> + + + setPositionForm((current) => ({ ...current, nameEn: event.target.value })) + } + /> + + + + setPositionForm((current) => ({ ...current, nameAm: event.target.value })) + } + /> + + +
    + setPositionForm((current) => ({ ...current, key: event.target.value }))} + /> + +
    +
    + + + +
    + + +
    +
    +
    + + +
    void handleSavePosition(event, true)}> + + + setPositionForm((current) => ({ ...current, nameEn: event.target.value })) + } + /> + + + + setPositionForm((current) => ({ ...current, nameAm: event.target.value })) + } + /> + + + setPositionForm((current) => ({ ...current, key: event.target.value }))} + /> + + + + +
    + + +
    +
    +
    + + +
    + + setInviteForm((current) => ({ ...current, nameEn: event.target.value }))} + /> + + + setInviteForm((current) => ({ ...current, nameAm: event.target.value }))} + /> + + + setInviteForm((current) => ({ ...current, email: event.target.value }))} + /> + + + setInviteForm((current) => ({ ...current, username: event.target.value }))} + /> + + + + setInviteForm((current) => ({ ...current, phoneNumber: event.target.value })) + } + /> + +
    + + +
    +
    +
    + + +
    +
    + + setAssignSearch(event.target.value)} + placeholder="Search organization employees" + /> +
    + +
    + {orgEmployeesLoading ? ( +
    + Loading organization employees... +
    + ) : assignableEmployees.length ? ( + assignableEmployees.map((employee) => { + const displayName = getLocaleLabel( + employee.name ?? employee.user?.name, + employee.user?.email ?? employee.id, + ); + const assignedPositions = employee.employeePositions + ?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? "")) + .filter(Boolean) + .join(", "); + + return ( +
    +
    +
    {displayName}
    +
    + {employee.user?.email || employee.user?.phoneNumber || "No contact info"} +
    + {assignedPositions ? ( +
    + Existing positions: {assignedPositions} +
    + ) : null} +
    + +
    + ); + }) + ) : ( +
    + No employees found for this organization. +
    + )} +
    +
    +
    + + +
    + {rolesLoading ? ( +
    + Loading roles... +
    + ) : ( +
    + {availableRoles.map((role) => ( + + ))} + {!availableRoles.length ? ( +
    + No assignable roles available. +
    + ) : null} +
    + )} +
    + + +
    +
    +
    +
    + ); +}; + +export default UserManagementPage;