diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b2a1f0c1a..49f32be76 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -21,6 +21,10 @@ import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -36,7 +40,10 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; config.get("database")!, }), SharedAuthModule, - IamModule.forRoot(), + IamModule.forRoot({ + applications: [EDR_FREIGHT_APPLICATION], + permissions: EDR_FREIGHT_PERMISSIONS, + }), BookingsModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 41e303985..395ff0386 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -5,6 +5,7 @@ import { Param, ParseUUIDPipe, Post, + Query, Put, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; @@ -27,6 +28,19 @@ export class BackofficeController { return this.backofficeService.createOrganizationUser(organizationId, dto); } + @Get("organizations/:orgId/employees") + @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) + getOrganizationEmployees( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Query("skip") skip?: string, + @Query("take") take?: string, + ) { + return this.backofficeService.getOrganizationEmployees(organizationId, { + skip, + take, + }); + } + @Get("organizations/:orgId/employee-users/:userId/roles") @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d6b68982f..7c7805b28 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -6,7 +6,7 @@ import { import { InjectRepository } from "@nestjs/typeorm"; import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; -import { DataSource, In, IsNull, Repository } from "typeorm"; +import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; @@ -21,10 +21,14 @@ const RESERVED_ROLE_KEYS = new Set([ "unit_admin", ]); const DEFAULT_USER_PASSWORD = "12345678"; +const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; +const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; @Injectable() export class BackofficeService { constructor( + @InjectRepository(Employee) + private readonly employeeRepository: Repository, @InjectRepository(Organization) private readonly organizationRepository: Repository, @InjectRepository(Role) @@ -51,6 +55,7 @@ export class BackofficeService { const email = dto.email.trim().toLowerCase(); const username = dto.username.trim().toLowerCase(); const phoneNumber = dto.phoneNumber?.trim() || undefined; + const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; const name = { en: dto.name.en.trim(), ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), @@ -168,6 +173,16 @@ export class BackofficeService { throw new NotFoundException("employee_create_failed"); } + const userId = user.id; + + if (!userId) { + throw new NotFoundException("user_create_failed"); + } + + if (assignOrganizationAdmin) { + await this.ensureOrganizationAdminAccess(manager, organizationId, userId); + } + return employee; }); } @@ -201,6 +216,45 @@ export class BackofficeService { })); } + async getOrganizationEmployees( + organizationId: string, + query: { skip?: string; take?: string }, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const take = Number.parseInt(query.take ?? "1000", 10); + const skip = Number.parseInt(query.skip ?? "0", 10); + + const employees = await this.employeeRepository.find({ + where: { + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + order: { + createdAt: "DESC", + }, + }); + + const deduplicated = this.mergeEmployeesByUser(employees); + + return { + count: deduplicated.length, + items: deduplicated.slice(skip, skip + take), + }; + } + async replaceEmployeeUserRoles( organizationId: string, userId: string, @@ -268,4 +322,104 @@ export class BackofficeService { throw new NotFoundException("user_not_found_in_organization"); } } + + private mergeEmployeesByUser(employees: Employee[]) { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.userId; + const employeeId = employee.id; + + if (!userId) { + if (employeeId) { + employeesByUserId.set(employeeId, employee); + } + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedEmployeePositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((employeePosition) => [ + employeePosition.id, + employeePosition, + ]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + user: existing.user ?? employee.user, + userId, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + employeePositions: mergedEmployeePositions, + }); + } + + return [...employeesByUserId.values()]; + } + + private async ensureOrganizationAdminAccess( + manager: EntityManager, + organizationId: string, + userId: string, + ) { + const roles = await manager.getRepository(Role).find({ + where: [ + { key: ORGANIZATION_ADMIN_ROLE_KEY }, + { key: EDR_ORG_MANAGER_ROLE_KEY }, + ], + select: { id: true, key: true }, + }); + + const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => { + const role = roles.find((item) => item.key === key); + + if (!role?.id) { + throw new NotFoundException(`required_role_not_seeded:${key}`); + } + + return { + id: role.id, + key: role.key, + }; + }); + + const existingRoleIds = new Set( + ( + await manager.getRepository(UserRole).find({ + where: { + userId, + organizationId, + }, + select: { roleId: true }, + }) + ).map((userRole) => userRole.roleId), + ); + + const rolesToInsert = requiredRoles + .filter((role) => !existingRoleIds.has(role.id)) + .map((role) => ({ + userId, + roleId: role.id, + organizationId, + })); + + if (!rolesToInsert.length) { + return; + } + + await manager.getRepository(UserRole).insert(rolesToInsert); + } } diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index 1623ac075..cf324a501 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -31,4 +31,9 @@ export class CreateOrganizationUserDto { @ApiProperty({ type: CreateOrganizationUserNameDto }) @IsObject() name!: CreateOrganizationUserNameDto; + + @ApiProperty({ required: false, default: false }) + @IsOptional() + @IsBoolean() + assignOrganizationAdmin?: boolean; } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts new file mode 100644 index 000000000..dda476841 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -0,0 +1,229 @@ +export type FreightSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +const IAM_PERMISSION_KEYS = { + activateEmployee: "can:activateEmployee", + activateUser: "can:activateUser", + createEmployee: "can:createEmployee", + createPositionPermission: "can:create:position_permission", + createUnit: "can:create:unit", + createUserRole: "can:create:user_role", + deactivateEmployee: "can:deactivateEmployee", + deletePositionPermission: "can:delete:position_permission", + deleteUnit: "can:delete:unit", + deleteUserRole: "can:delete:user_role", + findAllOrganization: "can:find_all:organization", + manageOrganizationAdmin: "manage:organizationAdmin", + manageUnitAdmin: "manage:unitAdmin", + updateUnit: "can:update:unit", + viewPositionPermission: "can:view:position_permission", + viewUserRole: "can:view:user_role", +} as const; + +export const EDR_FREIGHT_APPLICATION = { + id: "7f5a2175-c270-495b-bec9-d59ddbdab5d1", + key: "edr_freight_app", + name: { + am: "EDR Freight App", + en: "EDR Freight App", + }, +} as const; + +const EMPLOYEE_REGISTRATION_PERMISSIONS = [ + { + id: "62b5aa2d-4ef6-474d-913a-994568dce1c8", + key: "edr_freight_app:employee_registration:view", + name: { am: "View employee registration", en: "View employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "8072204d-26de-4e62-88aa-74afd916a0cb", + key: "edr_freight_app:employee_registration:create", + name: { am: "Create employee registration", en: "Create employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "b7dc55a6-ae7c-4558-8c4e-7d8ce5c7fa08", + key: "edr_freight_app:employee_registration:update", + name: { am: "Update employee registration", en: "Update employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7ef06121-bd31-4c0d-b36d-5401b4bfd05c", + key: "edr_freight_app:employee_registration:activate", + name: { am: "Activate employee registration", en: "Activate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "2688e144-7f0c-4704-8d59-e92b0c08117a", + key: "edr_freight_app:employee_registration:deactivate", + name: { am: "Deactivate employee registration", en: "Deactivate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const ROLE_ASSIGNMENT_PERMISSIONS = [ + { + id: "4de87873-e00d-4330-9b4f-f4fb065f49e0", + key: "edr_freight_app:role_assignment:view", + name: { am: "View role assignment", en: "View role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "36f022b4-4b94-4220-a46c-df7bd1a1b184", + key: "edr_freight_app:role_assignment:assign", + name: { am: "Assign role", en: "Assign role" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "c1f34177-a0ae-4a46-a24a-3281b9137bab", + key: "edr_freight_app:role_assignment:replace", + name: { am: "Replace role assignment", en: "Replace role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_UNIT_PERMISSIONS = [ + { + id: "2bfa2428-ec40-4588-9b01-dfacce6a2b82", + key: "edr_freight_app:hierarchy_units:view", + name: { am: "View hierarchy units", en: "View hierarchy units" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "1e92daff-9cc7-4a67-9994-879f34bfda16", + key: "edr_freight_app:hierarchy_units:create", + name: { am: "Create hierarchy unit", en: "Create hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "4ef2d8ad-c627-4448-b4b6-dd6b8b602dc1", + key: "edr_freight_app:hierarchy_units:update", + name: { am: "Update hierarchy unit", en: "Update hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "15353ac5-246b-42e6-9ac3-eb61c4f1cd22", + key: "edr_freight_app:hierarchy_units:delete", + name: { am: "Delete hierarchy unit", en: "Delete hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_POSITION_PERMISSIONS = [ + { + id: "37ff6f5b-9fb0-4139-af99-22fe54703029", + key: "edr_freight_app:hierarchy_positions:view", + name: { am: "View hierarchy positions", en: "View hierarchy positions" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "af6c091a-6448-4459-a635-c2181efd1de0", + key: "edr_freight_app:hierarchy_positions:create", + name: { am: "Create hierarchy position", en: "Create hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "e78f624d-b570-4cd6-8f16-12090a4a9d31", + key: "edr_freight_app:hierarchy_positions:update", + name: { am: "Update hierarchy position", en: "Update hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7fba7887-a365-4281-96ea-fb14582b047e", + key: "edr_freight_app:hierarchy_positions:delete", + name: { am: "Delete hierarchy position", en: "Delete hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "a33905ff-f2b8-40b9-a8cf-e2968f6f46fb", + key: "edr_freight_app:hierarchy_positions:change_parent", + name: { am: "Change hierarchy position parent", en: "Change hierarchy position parent" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS = [ + { + id: "b6ca90ff-3e95-4af2-bac8-fb298ca62080", + key: "edr_freight_app:hierarchy_employee_assignment:view", + name: { am: "View hierarchy employee assignment", en: "View hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "0637472f-d6b7-4332-85bb-eaa6a02205c1", + key: "edr_freight_app:hierarchy_employee_assignment:invite", + name: { am: "Invite hierarchy employee assignment", en: "Invite hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "de366c81-b6d1-4cf9-a5f1-a5c8a6fb5e7b", + key: "edr_freight_app:hierarchy_employee_assignment:assign", + name: { am: "Assign hierarchy employee assignment", en: "Assign hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const POSITION_TYPE_PERMISSIONS = [ + { + id: "f258fb51-2890-4c93-b024-271b09d705d0", + key: "edr_freight_app:position_types:view", + name: { am: "View position types", en: "View position types" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +export const EDR_FREIGHT_PERMISSIONS = [ + ...EMPLOYEE_REGISTRATION_PERMISSIONS, + ...ROLE_ASSIGNMENT_PERMISSIONS, + ...HIERARCHY_UNIT_PERMISSIONS, + ...HIERARCHY_POSITION_PERMISSIONS, + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, + ...POSITION_TYPE_PERMISSIONS, +]; + +export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ + { + key: "edr_employee", + name: { en: "EDR Employee" }, + permissionKeys: [ + "edr_freight_app:employee_registration:view", + "edr_freight_app:role_assignment:view", + "edr_freight_app:hierarchy_units:view", + "edr_freight_app:hierarchy_positions:view", + "edr_freight_app:hierarchy_employee_assignment:view", + "edr_freight_app:position_types:view", + ], + }, + { + key: "edr_org_manager", + name: { en: "EDR Org Manager" }, + permissionKeys: [ + ...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key), + IAM_PERMISSION_KEYS.createEmployee, + IAM_PERMISSION_KEYS.deactivateEmployee, + IAM_PERMISSION_KEYS.activateEmployee, + IAM_PERMISSION_KEYS.activateUser, + IAM_PERMISSION_KEYS.createUserRole, + IAM_PERMISSION_KEYS.deleteUserRole, + IAM_PERMISSION_KEYS.viewUserRole, + IAM_PERMISSION_KEYS.manageOrganizationAdmin, + IAM_PERMISSION_KEYS.manageUnitAdmin, + IAM_PERMISSION_KEYS.createUnit, + IAM_PERMISSION_KEYS.updateUnit, + IAM_PERMISSION_KEYS.deleteUnit, + IAM_PERMISSION_KEYS.createPositionPermission, + IAM_PERMISSION_KEYS.deletePositionPermission, + IAM_PERMISSION_KEYS.viewPositionPermission, + IAM_PERMISSION_KEYS.findAllOrganization, + ], + }, + { + key: "edr_customer", + name: { en: "EDR Customer" }, + permissionKeys: [], + }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index d03b9ef7f..49620b593 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -8,45 +8,17 @@ import { } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; +import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; + const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -type SeedPermission = { - key: string; - name: { en: string }; -}; - -type SeedRole = { - key: string; - name: { en: string }; - permissions: SeedPermission[]; -}; - type SeedOrganization = { id: string; key: string; }; -const SEED_ROLES: SeedRole[] = [ - { - key: "edr_employee", - name: { en: "EDR Employee" }, - permissions: [ - // { key: "permission:key", name: { en: "Permission Name" } }, - { key: "permission:key", name: { en: "Permission Name" } }, - ], - }, - { - key: "edr_customer", - name: { en: "EDR Customer" }, - permissions: [ - // { key: "permission:key", name: { en: "Permission Name" } }, - { key: "permission:key", name: { en: "Permission Name" } }, - ], - }, -]; - @Injectable() export class EdrOrgSeeder { private readonly logger = new Logger(EdrOrgSeeder.name); @@ -63,9 +35,8 @@ export class EdrOrgSeeder { const organization = await this.ensureOrganization(manager); await this.ensureOrganizationConfiguration(manager, organization.id); - await this.ensurePermissions(manager, SEED_ROLES); - await this.ensureRoles(manager, SEED_ROLES); - await this.ensureRolePermissions(manager, SEED_ROLES); + await this.ensureRoles(manager, EDR_FREIGHT_ROLES); + await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -127,34 +98,7 @@ export class EdrOrgSeeder { ); } - private collectPermissions(seedRoles: SeedRole[]) { - const permissionByKey = new Map(); - - for (const role of seedRoles) { - for (const permission of role.permissions) { - permissionByKey.set(permission.key, permission); - } - } - - return [...permissionByKey.values()]; - } - - private async ensurePermissions(manager: EntityManager, seedRoles: SeedRole[]) { - const permissions = this.collectPermissions(seedRoles); - - if (!permissions.length) { - this.logger.log("No EDR role permissions configured; skipping permission seed"); - return; - } - - await manager.getRepository(Permission).upsert(permissions, { - conflictPaths: { key: true }, - }); - - this.logger.log(`Ensured ${permissions.length} EDR permissions`); - } - - private async ensureRoles(manager: EntityManager, seedRoles: SeedRole[]) { + private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { await manager.getRepository(Role).upsert( seedRoles.map(({ key, name }) => ({ key, name })), { @@ -169,24 +113,24 @@ export class EdrOrgSeeder { private async ensureRolePermissions( manager: EntityManager, - seedRoles: SeedRole[], + seedRoles: FreightSeedRole[], ) { - const permissions = this.collectPermissions(seedRoles); + const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; - if (!permissions.length) { + if (!permissionKeys.length) { + this.logger.log("No EDR role permissions configured; skipping role-permission links"); return; } const roleRepository = manager.getRepository(Role); - const permissionRepository = manager.getRepository(Permission); const rolePermissionRepository = manager.getRepository(RolePermission); const roles = await roleRepository.find({ where: { key: In(seedRoles.map((role) => role.key)) }, select: { id: true, key: true }, }); - const seededPermissions = await permissionRepository.find({ - where: { key: In(permissions.map((permission) => permission.key)) }, + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, select: { id: true, key: true }, }); @@ -202,11 +146,11 @@ export class EdrOrgSeeder { throw new Error(`missing_role:${role.key}`); } - return role.permissions.map((permission) => { - const seededPermission = permissionByKey.get(permission.key); + return role.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); if (!seededPermission) { - throw new Error(`missing_permission:${permission.key}`); + throw new Error(`missing_permission:${permissionKey}`); } return { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c2b3a1a1a..7cc6a9935 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -21,6 +21,7 @@ import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; @@ -74,6 +75,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Employees", href: "/dashboard/user-management/employees", }, + { + label: "Position Types", + href: "/dashboard/user-management/position-types", + }, { label: "Permissions", href: "/dashboard/user-management/permissions", @@ -114,7 +119,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ]; const hasPermission = ( @@ -207,7 +211,8 @@ const App = () => { } /> } /> - } /> + } /> + {/* } /> */} } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx index 2e54608b0..eecca7a3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx @@ -100,7 +100,7 @@ const EmployeesPage = () => { const responses = await Promise.all( organizationIds.map((organizationId) => api.get>( - `/employees/${organizationId}/by-organization`, + `/backoffice/organizations/${organizationId}/employees`, { params: { skip: 0, diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx new file mode 100644 index 000000000..9895ae36a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx @@ -0,0 +1,1200 @@ +import { useEffect, useMemo, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; +import { CopyPlus, Plus, RefreshCw } 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; +} + +interface PositionTypeRecord { + id: string; + key: string; + name?: LocaleText; + isSystem?: boolean; + unitId?: string | null; + createdAt?: string; + updatedAt?: string | null; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; + applicationId?: string | null; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const PAGE_SIZE = 1000; + +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 emptyCreateForm = { + copyPermissionFromId: "", + key: "", + nameAm: "", + nameEn: "", +}; + +type PositionTypeEditFormState = { + key: string; + nameAm: string; + nameEn: string; +}; + +const emptyEditForm: PositionTypeEditFormState = { + key: "", + nameAm: "", + nameEn: "", +}; + +const toggleSelection = ( + currentIds: string[], + targetIds: string[], + checked: boolean, +) => { + if (checked) { + return [...new Set([...currentIds, ...targetIds])]; + } + + return currentIds.filter((id) => !targetIds.includes(id)); +}; + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => + 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 formatDate = (value?: string | null) => { + if (!value) { + return "-"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +}; + +const PositionTypesPage = () => { + const { user } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [units, setUnits] = useState([]); + const [positionTypes, setPositionTypes] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [selectedUnitId, setSelectedUnitId] = useState(""); + const [loadingOrganizations, setLoadingOrganizations] = useState(true); + const [loadingUnits, setLoadingUnits] = useState(false); + const [loadingPositionTypes, setLoadingPositionTypes] = useState(false); + const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedPositionType, setSelectedPositionType] = useState(null); + const [positionTypePermissions, setPositionTypePermissions] = useState([]); + const [allPermissions, setAllPermissions] = useState([]); + const [permissionsLoading, setPermissionsLoading] = useState(false); + const [permissionsError, setPermissionsError] = useState(null); + const [permissionSearch, setPermissionSearch] = useState(""); + const [selectedPermissionIds, setSelectedPermissionIds] = useState([]); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState(emptyCreateForm); + const [createPermissionSearch, setCreatePermissionSearch] = useState(""); + const [createPermissionIds, setCreatePermissionIds] = useState([]); + const [createError, setCreateError] = useState(null); + const [editForm, setEditForm] = useState(emptyEditForm); + + const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); + const allowedOrgIds = useMemo( + () => + new Set( + (user?.employee ?? []) + .map((employee) => employee.organizationId) + .filter((organizationId): organizationId is string => Boolean(organizationId)), + ), + [user?.employee], + ); + + const visibleOrganizations = useMemo(() => { + if (isSuperAdmin) { + return organizations; + } + + return organizations.filter((organization) => allowedOrgIds.has(organization.id)); + }, [allowedOrgIds, isSuperAdmin, organizations]); + + const selectedOrganization = + visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null; + const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null; + const availableCopySources = useMemo( + () => positionTypes.filter((positionType) => positionType.id !== selectedPositionType?.id), + [positionTypes, selectedPositionType?.id], + ); + const filteredPermissions = useMemo(() => { + const query = permissionSearch.trim().toLowerCase(); + + return allPermissions.filter((permission) => { + if (!query) { + return true; + } + + const label = getLocaleLabel(permission.name, permission.key).toLowerCase(); + return label.includes(query) || permission.key.toLowerCase().includes(query); + }); + }, [allPermissions, permissionSearch]); + const filteredCreatePermissions = useMemo(() => { + const query = createPermissionSearch.trim().toLowerCase(); + + return allPermissions.filter((permission) => { + if (!query) { + return true; + } + + const label = getLocaleLabel(permission.name, permission.key).toLowerCase(); + return label.includes(query) || permission.key.toLowerCase().includes(query); + }); + }, [allPermissions, createPermissionSearch]); + const allFilteredPermissionIds = filteredPermissions.map((permission) => permission.id); + const allFilteredCreatePermissionIds = filteredCreatePermissions.map((permission) => permission.id); + const areAllFilteredPermissionsSelected = + allFilteredPermissionIds.length > 0 && + allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id)); + const areAllFilteredCreatePermissionsSelected = + allFilteredCreatePermissionIds.length > 0 && + allFilteredCreatePermissionIds.every((id) => createPermissionIds.includes(id)); + + const loadPositionTypes = async (unitId: string) => { + const response = await api.get>( + `/position-types/list-with-commons/${unitId}`, + { + params: { + skip: 0, + take: PAGE_SIZE, + orderBy: "createdAt:Desc", + }, + }, + ); + + return getItems(response.data); + }; + + const loadPermissionsForPositionType = async (positionTypeId: string) => { + const response = await api.get>( + `/position-type-permissions/given-first/${positionTypeId}`, + ); + + return getItems(response.data); + }; + + useEffect(() => { + let isMounted = true; + + const loadOrganizations = async () => { + setLoadingOrganizations(true); + setErrorMessage(null); + + try { + const response = await api.get>("/organizations"); + + if (!isMounted) { + return; + } + + setOrganizations(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load organizations." + : "Unable to load organizations.", + ); + } finally { + if (isMounted) { + setLoadingOrganizations(false); + } + } + }; + + void loadOrganizations(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + let isMounted = true; + + const loadPermissionsCatalog = async () => { + setLoadingPermissionsCatalog(true); + + try { + const response = await api.get>("/permissions", { + params: { + skip: 0, + take: 2000, + }, + }); + + if (!isMounted) { + return; + } + + setAllPermissions(getItems(response.data)); + } catch { + if (!isMounted) { + return; + } + + setAllPermissions([]); + } finally { + if (isMounted) { + setLoadingPermissionsCatalog(false); + } + } + }; + + void loadPermissionsCatalog(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + if (!visibleOrganizations.length) { + setSelectedOrgId(""); + setSelectedUnitId(""); + setUnits([]); + setPositionTypes([]); + return; + } + + if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { + return; + } + + setSelectedOrgId(visibleOrganizations[0]?.id ?? ""); + }, [selectedOrgId, visibleOrganizations]); + + useEffect(() => { + if (!selectedOrgId) { + setUnits([]); + setSelectedUnitId(""); + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadUnits = async () => { + setLoadingUnits(true); + setErrorMessage(null); + setSelectedUnitId(""); + setPositionTypes([]); + + try { + const response = await api.get>(`/units/list/${selectedOrgId}`); + const items = getItems(response.data); + + if (!isMounted) { + return; + } + + setUnits(items); + setSelectedUnitId(items[0]?.id ?? ""); + } catch (error) { + if (!isMounted) { + return; + } + + setUnits([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load units." + : "Unable to load units.", + ); + } finally { + if (isMounted) { + setLoadingUnits(false); + } + } + }; + + void loadUnits(); + + return () => { + isMounted = false; + }; + }, [selectedOrgId]); + + useEffect(() => { + if (!selectedUnitId) { + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadItems = async () => { + setLoadingPositionTypes(true); + setErrorMessage(null); + + try { + const items = await loadPositionTypes(selectedUnitId); + + if (!isMounted) { + return; + } + + setPositionTypes(items); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypes([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position types." + : "Unable to load position types.", + ); + } finally { + if (isMounted) { + setLoadingPositionTypes(false); + } + } + }; + + void loadItems(); + + return () => { + isMounted = false; + }; + }, [selectedUnitId]); + + useEffect(() => { + if (!selectedPositionType) { + setPositionTypePermissions([]); + setSelectedPermissionIds([]); + setEditForm(emptyEditForm); + setPermissionsError(null); + setPermissionsLoading(false); + setPermissionSearch(""); + return; + } + + setEditForm({ + key: selectedPositionType.key, + nameAm: selectedPositionType.name?.am ?? "", + nameEn: selectedPositionType.name?.en ?? "", + }); + + let isMounted = true; + + const loadPermissions = async () => { + setPermissionsLoading(true); + setPermissionsError(null); + + try { + const items = await loadPermissionsForPositionType(selectedPositionType.id); + + if (!isMounted) { + return; + } + + setPositionTypePermissions(items); + setSelectedPermissionIds(items.map((permission) => permission.id)); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypePermissions([]); + setPermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position type permissions." + : "Unable to load position type permissions.", + ); + } finally { + if (isMounted) { + setPermissionsLoading(false); + } + } + }; + + void loadPermissions(); + + return () => { + isMounted = false; + }; + }, [selectedPositionType]); + + const handleRefresh = async () => { + if (!selectedUnitId) { + return; + } + + setLoadingPositionTypes(true); + setErrorMessage(null); + + try { + const items = await loadPositionTypes(selectedUnitId); + setPositionTypes(items); + + if (selectedPositionType) { + const nextSelected = items.find((item) => item.id === selectedPositionType.id) ?? selectedPositionType; + setSelectedPositionType(nextSelected); + } + } catch (error) { + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to refresh position types." + : "Unable to refresh position types.", + ); + } finally { + setLoadingPositionTypes(false); + } + }; + + const openCreateDialog = () => { + setCreateForm(emptyCreateForm); + setCreatePermissionIds([]); + setCreatePermissionSearch(""); + setCreateError(null); + setIsCreateOpen(true); + }; + + const handleSelectCopySource = async (positionTypeId: string) => { + setCreateForm((current) => ({ ...current, copyPermissionFromId: positionTypeId })); + + if (!positionTypeId) { + setCreatePermissionIds([]); + return; + } + + try { + const copiedPermissions = await loadPermissionsForPositionType(positionTypeId); + setCreatePermissionIds(copiedPermissions.map((permission) => permission.id)); + } catch (error) { + setCreateError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to copy permissions." + : "Unable to copy permissions.", + ); + } + }; + + const handleCreatePositionType = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedUnitId) { + setCreateError("Select a unit before creating a position type."); + return; + } + + setSubmitting(true); + setCreateError(null); + + try { + const response = await api.post("/position-types", { + key: createForm.key.trim(), + name: { + am: createForm.nameAm.trim(), + en: createForm.nameEn.trim(), + }, + unitId: selectedUnitId, + }); + + const createdPositionType = response.data; + + await api.post("/position-type-permissions/assign-seconds-for-first", { + firstId: createdPositionType.id, + secondIds: createPermissionIds, + }); + + setIsCreateOpen(false); + setCreateForm(emptyCreateForm); + setCreatePermissionIds([]); + await handleRefresh(); + } catch (error) { + setCreateError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to create position type." + : "Unable to create position type.", + ); + } finally { + setSubmitting(false); + } + }; + + const handleSavePositionTypeChanges = async () => { + if (!selectedPositionType) { + return; + } + + setSubmitting(true); + setPermissionsError(null); + + try { + if (!selectedPositionType.isSystem) { + await api.put(`/position-types/${selectedPositionType.id}`, { + key: editForm.key.trim(), + name: { + am: editForm.nameAm.trim(), + en: editForm.nameEn.trim(), + }, + unitId: selectedPositionType.unitId, + }); + } + + await api.post("/position-type-permissions/assign-seconds-for-first", { + firstId: selectedPositionType.id, + secondIds: selectedPermissionIds, + }); + + const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([ + loadPermissionsForPositionType(selectedPositionType.id), + selectedUnitId ? loadPositionTypes(selectedUnitId) : Promise.resolve(positionTypes), + ]); + + setPositionTypePermissions(refreshedPermissions); + setSelectedPermissionIds(refreshedPermissions.map((permission) => permission.id)); + setPositionTypes(refreshedPositionTypes); + + const refreshedSelected = refreshedPositionTypes.find((item) => item.id === selectedPositionType.id); + if (refreshedSelected) { + setSelectedPositionType(refreshedSelected); + } + } catch (error) { + setPermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to update position type." + : "Unable to update position type.", + ); + } finally { + setSubmitting(false); + } + }; + + const handleSavePermissions = async () => { + if (!selectedPositionType) { + return; + } + + setSubmitting(true); + setPermissionsError(null); + + try { + await api.post("/position-type-permissions/assign-seconds-for-first", { + firstId: selectedPositionType.id, + secondIds: selectedPermissionIds, + }); + + const items = await loadPermissionsForPositionType(selectedPositionType.id); + setPositionTypePermissions(items); + setSelectedPermissionIds(items.map((permission) => permission.id)); + } catch (error) { + setPermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to update position type permissions." + : "Unable to update position type permissions.", + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

+ User Management +

+
+
+

Position Type

+

+ Browse position types for a selected organization unit, add new ones, and manage their permissions. +

+
+
+
+ {positionTypes.length} position types +
+ + +
+
+
+ +
+
+

+ Organization +

+ +
+ +
+

+ Unit +

+ +
+ +

+ {selectedOrganization && selectedUnit + ? `Showing position types for ${getLocaleLabel(selectedUnit.name, selectedUnit.key)} in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}.` + : "Select an organization and unit to load position types."} +

+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : loadingOrganizations || loadingUnits || loadingPositionTypes ? ( +
+ Loading position types... +
+ ) : !visibleOrganizations.length ? ( +
+ No organization scope is available for this account. +
+ ) : !selectedOrgId ? ( +
+ Select an organization to continue. +
+ ) : !units.length ? ( +
+ No units are available for the selected organization. +
+ ) : !selectedUnitId ? ( +
+ Select a unit to load position types. +
+ ) : positionTypes.length ? ( +
+ + + + + + + + + + + + + {positionTypes.map((positionType) => ( + setSelectedPositionType(positionType)} + className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20" + > + + + + + + + + ))} + +
NameKeyScopeUnit IDCreated AtUpdated At
+ {getLocaleLabel(positionType.name, positionType.key)} + {positionType.key} + {positionType.isSystem ? "System" : "Unit"} + + {positionType.unitId ?? "-"} + + {formatDate(positionType.createdAt)} + + {formatDate(positionType.updatedAt)} +
+
+ ) : ( +
+ No position types were found for the selected unit. +
+ )} +
+ + !open && setSelectedPositionType(null)} + > + + + + {selectedPositionType + ? getLocaleLabel(selectedPositionType.name, selectedPositionType.key) + : "Position type details"} + + + {selectedPositionType + ? `Review and update the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.` + : undefined} + + + + {selectedPositionType ? ( +
+
+
+

+ Position type +

+

+ {getLocaleLabel(selectedPositionType.name, selectedPositionType.key)} +

+
+
+

+ Key +

+

+ {selectedPositionType.key} +

+
+
+

+ Scope +

+

+ {selectedPositionType.isSystem ? "System" : "Unit"} +

+
+
+

+ Unit ID +

+

+ {selectedPositionType.unitId ?? "-"} +

+
+
+ +
+
+ + + + {selectedPositionType.isSystem ? ( +

+ System position types keep their name and key, but you can still manage permissions here. +

+ ) : null} +
+ +
+

Permissions

+
+ {selectedPermissionIds.length} permissions selected +
+
+ + {permissionsLoading ? ( +
+ Loading position type permissions... +
+ ) : permissionsError ? ( +
+ {permissionsError} +
+ ) : ( +
+ setPermissionSearch(event.target.value)} + placeholder="Search permissions by name or key" + /> + + + + {loadingPermissionsCatalog ? ( +
+ Loading permissions catalog... +
+ ) : filteredPermissions.length ? ( +
+ {filteredPermissions.map((permission) => ( + + ))} +
+ ) : ( +
+ No permissions match the current search. +
+ )} + +
+ + +
+
+ )} +
+
+ ) : null} +
+
+ + + + + Create position type + + Add a new position type for the selected unit and optionally copy permissions from an existing one. + + + +
void handleCreatePositionType(event)}> +
+ + + + + + + +
+ +
+
+

Permissions

+
+ {createPermissionIds.length} selected +
+
+ + setCreatePermissionSearch(event.target.value)} + placeholder="Search permissions by name or key" + /> + + + + {loadingPermissionsCatalog ? ( +
+ Loading permissions catalog... +
+ ) : filteredCreatePermissions.length ? ( +
+ {filteredCreatePermissions.map((permission) => ( + + ))} +
+ ) : ( +
+ No permissions match the current search. +
+ )} +
+ + {createForm.copyPermissionFromId ? ( +
+
+ + The new position type will inherit permissions from the selected source. +
+
+ ) : null} + + {createError ? ( +
+ {createError} +
+ ) : null} + +
+ + +
+
+
+
+
+ ); +}; + +export default PositionTypesPage; 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 index 88fa34647..8c3806165 100644 --- 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 @@ -188,6 +188,46 @@ const getItems = (payload: ListResponse | T[] | undefined | null) => { return payload.items ?? payload.data ?? []; }; +const mergeEmployeesByUser = (employees: EmployeeRecord[]) => { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.user?.id; + + if (!userId) { + employeesByUserId.set(employee.id, employee); + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedPositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((position) => [position.id, position]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + user: existing.user ?? employee.user, + employeePositions: mergedPositions, + }); + } + + return [...employeesByUserId.values()]; +}; + const toInternalKey = (value: string) => value .trim() @@ -611,9 +651,9 @@ const UserManagementPage = () => { try { const response = await api.get>( - `/employees/${organizationId}/by-organization`, + `/backoffice/organizations/${organizationId}/employees`, ); - setOrgEmployees(getItems(response.data)); + setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); } catch { setOrgEmployees([]); } finally { @@ -745,13 +785,19 @@ const UserManagementPage = () => { const refreshSelectedUnit = useCallback(async () => { if (!selectedUnitId) { setPositions([]); + setPositionTypes([]); + setPositionTypesError(null); setPositionMembers([]); setUnitAdminUserIds(new Set()); return; } - await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]); - }, [loadPositions, loadUnitAdmins, selectedUnitId]); + await Promise.all([ + loadPositions(selectedUnitId), + loadPositionTypes(selectedUnitId), + loadUnitAdmins(selectedUnitId), + ]); + }, [loadPositionTypes, loadPositions, loadUnitAdmins, selectedUnitId]); useEffect(() => { void loadOrganizations(); @@ -772,7 +818,7 @@ const UserManagementPage = () => { return; } - setSelectedOrgId(null); + setSelectedOrgId(visibleOrganizations[0]?.id ?? null); setSelectedOrgConfiguration(null); setUnits([]); setPositions([]); @@ -780,6 +826,14 @@ const UserManagementPage = () => { setOrgEmployees([]); }, [selectedOrgId, visibleOrganizations]); + useEffect(() => { + if (!selectedOrgId) { + return; + } + + void refreshSelectedOrg(); + }, [refreshSelectedOrg, selectedOrgId]); + useEffect(() => { if (!selectedOrgId) { setUnits([]); @@ -802,9 +856,23 @@ const UserManagementPage = () => { return; } - if (!units.some((unit) => unit.id === selectedUnitId)) { - setSelectedUnitId(null); + if (units.some((unit) => unit.id === selectedUnitId)) { + return; } + + setSelectedUnitId(units[0]?.id ?? null); + }, [selectedOrgId, selectedUnitId, units]); + + useEffect(() => { + if (!selectedOrgId || !units.length) { + return; + } + + if (selectedUnitId && units.some((unit) => unit.id === selectedUnitId)) { + return; + } + + setSelectedUnitId(units[0]?.id ?? null); }, [selectedOrgId, selectedUnitId, units]); useEffect(() => { @@ -820,6 +888,14 @@ const UserManagementPage = () => { } }, [selectedUnitId]); + useEffect(() => { + if (!selectedUnitId) { + return; + } + + void refreshSelectedUnit(); + }, [refreshSelectedUnit, selectedUnitId]); + useEffect(() => { if (!selectedUnitId || !selectedPositionId) { return; @@ -882,15 +958,11 @@ const UserManagementPage = () => { setSelectedPositionId(null); setExpandedDepartmentIds(new Set()); setPositions([]); + setPositionTypes([]); + setPositionTypesError(null); 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) => { diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx index 63402aa1b..70d58d313 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx @@ -60,6 +60,7 @@ interface UserFormState { email: string; username: string; phoneNumber: string; + assignOrganizationAdmin: boolean; } interface ListResponse { @@ -75,6 +76,7 @@ const emptyUserForm: UserFormState = { email: "", username: "", phoneNumber: "", + assignOrganizationAdmin: false, }; const inputClassName = @@ -102,6 +104,46 @@ const getItems = (payload: ListResponse | T[] | undefined | null) => { return payload.items ?? payload.data ?? []; }; +const mergeEmployeesByUser = (employees: EmployeeRecord[]) => { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.user?.id; + + if (!userId) { + employeesByUserId.set(employee.id, employee); + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedPositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((position) => [position.id, position]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + user: existing.user ?? employee.user, + employeePositions: mergedPositions, + }); + } + + return [...employeesByUserId.values()]; +}; + const getErrorMessage = (error: unknown, fallback: string) => { if (isAxiosError(error)) { const message = error.response?.data?.message; @@ -229,9 +271,9 @@ const UsersPage = () => { try { const response = await api.get>( - `/employees/${organizationId}/by-organization`, + `/backoffice/organizations/${organizationId}/employees`, ); - setOrgEmployees(getItems(response.data)); + setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); } catch { setOrgEmployees([]); } finally { @@ -346,14 +388,23 @@ const UsersPage = () => { am: createUserForm.nameAm.trim(), en: createUserForm.nameEn.trim(), }, + assignOrganizationAdmin: createUserForm.assignOrganizationAdmin, }, ); + const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin; setCreateUserForm(emptyUserForm); setIsCreateUserOpen(false); - setActionSuccess("User created. Default password: 12345678."); + setActionSuccess( + shouldAssignOrganizationAdmin + ? "User created as organization admin. Default password: 12345678." + : "User created. Default password: 12345678.", + ); await loadOrgEmployees(selectedOrgId); - await openManageRolesDialog(response.data); + + if (!shouldAssignOrganizationAdmin) { + await openManageRolesDialog(response.data); + } } catch (error) { setActionError(getErrorMessage(error, "Failed to create user.")); } finally { @@ -669,6 +720,24 @@ const UsersPage = () => { onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))} /> +