diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 99c1e0329..f8e26a07a 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -1649,6 +1649,8 @@ "notFoundError": "የፈለጉትን መረጃ አልተገኘም።", "fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።", "serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።", + "userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።", + "unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።", "attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።", "replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!", "replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።", @@ -2653,6 +2655,7 @@ "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ", "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", "permissionsSelected": "{{count}} ተመርጠዋል", "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", @@ -7491,12 +7494,22 @@ "loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።", "pickerError": "ድርጅቶችን መጫን አልተሳካም።", "addAdmin": "አስተዳዳሪ ጨምር", + "managePermissions": "ፍቃዶችን ያስተዳድሩ", "add": { "title": "አስተዳዳሪ ጨምር", "description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።", "submit": "አስተዳዳሪ ጨምር", "inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።", "noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።" + }, + "permissions": { + "title": "የድርጅት አስተዳዳሪ ፍቃዶች", + "subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።", + "backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ", + "roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።", + "saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።", + "saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።", + "cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።" } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index f1357c2bf..66deb4395 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -1669,6 +1669,8 @@ "notFoundError": "We couldn't find what you were looking for.", "fileTooLarge": "The file is too large. Please reduce the file size and try again.", "serverError": "Something went wrong on our side. Please try again in a moment.", + "userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.", + "unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.", "attachmentDeleted": "Attachment deleted successfully.", "replyAdded": "Reply added successfully!", "replyError": "An error occurred while adding the reply.", @@ -2762,6 +2764,7 @@ "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "selectUnitToCopy": "Select a unit to see the position types you can copy from", "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", "permissionsSelected": "{{count}} selected", "positionTypeCreated": "Position type created", @@ -7492,12 +7495,22 @@ "loadError": "Failed to load admins.", "pickerError": "Failed to load organizations.", "addAdmin": "Add Admin", + "managePermissions": "Manage Permissions", "add": { "title": "Add Admin", "description": "Create a user account and grant admin access in this organization.", "submit": "Add Admin", "inviteNote": "The user is created and receives an SMS invitation to set their password.", "noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin." + }, + "permissions": { + "title": "Organization Admin Permissions", + "subtitle": "Choose what every Organization Admin can do across the platform.", + "backToAdmins": "Back to Organization Admins", + "roleNotFound": "Could not find the Organization Admin role.", + "saved": "Organization Admin permissions updated.", + "saveFailed": "Failed to update Organization Admin permissions.", + "cannotClearAll": "Saved. Permissions were left unchanged — the Organization Admin role must keep at least one permission." } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index bd5e17fae..b888a2419 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1169,6 +1169,8 @@ "notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.", "fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.", "serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.", + "userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.", + "unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.", "attachmentDeleted": "Pièce jointe supprimée avec succès.", "replyAdded": "Réponse ajoutée avec succès !", "replyError": "Une erreur s’est produite lors de l’ajout de la réponse.", @@ -1888,6 +1890,7 @@ "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier", "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", "permissionsSelected": "{{count}} sélectionné(s)", "positionTypeCreated": "Type de poste créé", diff --git a/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts b/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts index f5d37d18d..9c42868ba 100644 --- a/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts +++ b/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts @@ -54,6 +54,28 @@ const extractMessage = (value: unknown): string | null => { } }; +// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw, +// untranslated code string as the message — no i18n on that side — so it +// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map +// known codes to a friendly, translated message before falling back to the +// raw text. `unit_employee_limit_reached` carries its configured limit after +// a colon (e.g. "unit_employee_limit_reached:5"). +const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:"; + +const mapIamErrorCode = ( + raw: string | null, + t: (key: string, options?: Record) => string, +): string | null => { + if (!raw) return null; + if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) { + return t("msg.unitEmployeeLimitReached", { + limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length), + }); + } + if (raw === "user_role_not_found") return t("msg.userRoleNotFound"); + return null; +}; + // Maps an HTTP status code to the i18n key used when no backend message is available. const statusKeyFor = (status: number | undefined): string => { if (status === 400 || status === 422) return "msg.validationError"; @@ -111,7 +133,7 @@ const parseBlobBody = async (blob: Blob): Promise => { }; export const useErrorHandler = ( - t: (key: string) => string, + t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( async (err: unknown): Promise => { @@ -134,15 +156,15 @@ export const useErrorHandler = ( const fromException = extractMessage((data as any)?.exception?.response) ?? extractMessage((data as any)?.exception); - if (fromException) return fromException; + if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); - if (fromData) return fromData; + if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; } if (err instanceof Error) { const fromError = extractMessage(err.message); - if (fromError) return fromError; + if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); @@ -186,7 +208,7 @@ export const useErrorHandler = ( }; export const useClientErrorHandler = ( - t: (key: string) => string, + t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( (err: unknown): string => { @@ -205,13 +227,13 @@ export const useClientErrorHandler = ( const fromException = extractMessage(data?.exception?.response) ?? extractMessage(data?.exception); - if (fromException) return fromException; + if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); - if (fromData) return fromData; + if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; const fromError = extractMessage((err as any).message); - if (fromError) return fromError; + if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx new file mode 100644 index 000000000..a98d38143 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/common/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "@/user-management/components/position-management/PermissionSearch"; +import { getRoles } from "@/super-admin/services/api/roleService"; +import { + assignPermissionsToRole, + getPermissionsByRoleId, +} from "@/super-admin/services/api/rolePermissionService"; +import { ORG_ADMIN_ROLE_KEY } from "./OrgAdminsColumnDefn"; + +// The Organization Admin role is a fixed, singleton role (unlike position +// types, which are org/unit-scoped) — so this page has no picker, just the +// one role's permission set. +export default function OrgAdminPermissionsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const queryClient = useQueryClient(); + + const [selectedApplicationId, setSelectedApplicationId] = useState(""); + const [permissions, setPermissions] = useState([]); + const hasLoadedPermissions = useRef(false); + // Permissions the role had when the page opened. Needed because the API + // cannot represent "no permissions" (see the save handler). + const loadedPermissionCount = useRef(0); + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { + data: rolesResponse, + isLoading: isLoadingRoles, + isError: isRolesError, + } = useQuery({ queryKey: ["roles"], queryFn: getRoles }); + + const orgAdminRole = useMemo( + () => rolesResponse?.data?.items?.find((r) => r.key === ORG_ADMIN_ROLE_KEY), + [rolesResponse], + ); + + const { + data: rolePermissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + isLoading: isLoadingPermissions, + } = useQuery({ + queryKey: ["role-permissions", orgAdminRole?.id], + queryFn: () => getPermissionsByRoleId(orgAdminRole!.id), + enabled: !!orgAdminRole?.id, + }); + + useEffect(() => { + if (hasLoadedPermissions.current) return; + if (!isPermissionsSuccess && !isPermissionsError) return; + const ids = rolePermissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + setPermissions(ids); + hasLoadedPermissions.current = true; + }, [isPermissionsSuccess, isPermissionsError, rolePermissionsResponse]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + setPermissions((prev) => + checked ? [...prev, permissionId] : prev.filter((id) => id !== permissionId), + ); + }; + + const mustClearAll = + permissions.length === 0 && loadedPermissionCount.current > 0; + + const { mutate: save, isPending: isSaving } = useMutation({ + mutationFn: async () => { + if (!orgAdminRole?.id || permissions.length === 0) return; + await assignPermissionsToRole({ + firstId: orgAdminRole.id, + secondIds: permissions, + }); + }, + onSuccess: () => { + loadedPermissionCount.current = permissions.length; + queryClient.invalidateQueries({ queryKey: ["role-permissions"] }); + toast[mustClearAll ? "warning" : "success"]( + t( + mustClearAll + ? "orgAdmins.permissions.cannotClearAll" + : "orgAdmins.permissions.saved", + ), + ); + }, + onError: () => { + toast.error(t("orgAdmins.permissions.saveFailed")); + }, + }); + + const selectedPermissionCount = permissions.length; + + return ( +
+ + + + + {t("orgAdmins.permissions.backToAdmins")} + + + {t("orgAdmins.permissions.title")} + +

+ {t("orgAdmins.permissions.subtitle")} +

+
+ + {isLoadingRoles ? ( +
+ {t("common.loading")} +
+ ) : isRolesError || !orgAdminRole ? ( +
+ {t("orgAdmins.permissions.roleNotFound")} +
+ ) : ( + <> +
+ + +
+ +
+ + +
+ +
+ +
+ + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx index 97c3bfbc6..8bbe7c587 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -32,13 +32,15 @@ export interface AdminRoleInfo { /** * all-admins/:id returns users who are org admins of the org OR unit admins of * one of its units; userRoles carries every role of the user, so match the org - * explicitly for the org-admin grant. + * explicitly for the org-admin grant. The unit-admin grant carries no + * organizationId of its own, only a unitId, so orgUnitIds (every unit that + * belongs to the selected org) is required to tell a same-org unit-admin + * grant apart from a same-user unit-admin grant in a different org. */ -// ponytail: unit relation isn't loaded, so a unit_admin grant from another org -// can't be told apart — acceptable, the server only returns admins of this org. export function getAdminRoleInfo( admin: OrgAdminUser, selectedOrgId: string, + orgUnitIds: Set, ): AdminRoleInfo { const roles = admin.userRoles ?? []; const isOrgAdmin = roles.some( @@ -47,7 +49,10 @@ export function getAdminRoleInfo( r.organizationId === selectedOrgId, ); const unitRole = roles.find( - (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + (r) => + r.role?.key === UNIT_ADMIN_ROLE_KEY && + !!r.unitId && + orgUnitIds.has(r.unitId), ); return { isOrgAdmin, @@ -58,6 +63,7 @@ export function getAdminRoleInfo( interface ColumnCallbacks { selectedOrgId: string; + orgUnitIds: Set; localizedName: (name?: { am?: string; en?: string }) => string; onEdit: (admin: OrgAdminUser) => void; onResend: (admin: OrgAdminUser) => void; @@ -67,6 +73,7 @@ interface ColumnCallbacks { export function getOrgAdminsColumnDefn({ selectedOrgId, + orgUnitIds, localizedName, onEdit, onResend, @@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({ id: "role", header: () => t("orgAdmins.columns.role"), cell: ({ row }) => { - const info = getAdminRoleInfo(row.original, selectedOrgId); + const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds); return (
{info.isOrgAdmin && ( @@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({ enableHiding: false, cell: ({ row }) => { const admin = row.original; - const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds); return ( diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx index d5844a603..adebd98a5 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -1,7 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + Building2, + Loader2, + Plus, + ShieldCheck, + UserPlus, + Users2, +} from "lucide-react"; import { Button } from "@/shared/common/ui/button"; import { Card, @@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge"; import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; import { useLocalizedName } from "@/shared/common/localizedName"; import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; import { OrgAdminUser, useOrgAdmins, @@ -81,6 +91,22 @@ export default function OrgAdminsPage() { skip: pageIndex * pageSize, }); + const { getList: getUnitList } = useUnit(); + // A unit-admin grant only carries a unitId, no organizationId — this is the + // set that tells "unit_admin of this org" apart from "unit_admin of some + // other org the same user also administers" (see getAdminRoleInfo). + const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", { + take: 3000, + skip: 0, + }); + const orgUnitIds = useMemo( + () => + new Set( + (orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [orgUnitsResponse], + ); + useEffect(() => { setPageIndex(0); }, [selectedOrg?.id, pageSize]); @@ -170,6 +196,7 @@ export default function OrgAdminsPage() { () => getOrgAdminsColumnDefn({ selectedOrgId: selectedOrg?.id ?? "", + orgUnitIds, localizedName: localizedName as (name?: { am?: string; en?: string; @@ -183,19 +210,29 @@ export default function OrgAdminsPage() { onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), }), // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedOrg?.id], + [selectedOrg?.id, orgUnitIds], ); return (
- - {t("orgAdmins.title")} - -

- {t("orgAdmins.subtitle")} -

+
+
+ + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ +
{/* Org selector + summary */} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts new file mode 100644 index 000000000..1d96edc1f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts @@ -0,0 +1,25 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto"; +import { AxiosResponse } from "axios"; + +export interface AssignRolePermissionsPayload { + firstId: string; + secondIds: string[]; +} + +// GET /role-permissions/given-first/{roleId} +export const getPermissionsByRoleId = async ( + roleId: string, +): Promise> => + axiosInstance.get(`/role-permissions/given-first/${roleId}`, { + headers: withHeaders(), + }); + +// POST /role-permissions/assign-seconds-for-first +export const assignPermissionsToRole = async ( + payload: AssignRolePermissionsPayload, +): Promise> => + axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, { + headers: withHeaders(), + }); diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts new file mode 100644 index 000000000..6ede479d8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts @@ -0,0 +1,20 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { AxiosResponse } from "axios"; + +export interface RoleDto { + id: string; + name: { am: string; en: string }; + key: string; +} + +export interface RoleListResponse { + count: number; + items: RoleDto[]; +} + +export const getRoles = async (): Promise> => + axiosInstance.get("/roles", { + headers: withHeaders(), + params: { take: 100 }, + }); diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index f8fc0cfdd..29ac1486f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -115,6 +115,7 @@ export const CreatePositionForm = ({ }); const selectedOrganizationId = form.watch("organizationId"); + const selectedUnitId = form.watch("unitId"); const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( "Org", @@ -163,36 +164,32 @@ export const CreatePositionForm = ({ enabled: mode === "edit" && !!positionTypeId, }); - // A position type belongs to a unit, and a unit to an organization — IAM has - // no organizationId on the type itself and no organization-scoped route, so - // the picked org narrows the list through its units. isSystem types are the - // shared "commons" and stay available to every organization. - const orgUnitIds = useMemo( - () => - new Set( - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), - ), - [unitsResponse], - ); - + // A position type belongs to a single unit — scope copy sources to the + // selected unit, same as the "Select Unit" filter on the position list page. + // isSystem types are the shared "commons" and stay available everywhere. const copyFromOptions = useMemo(() => { - if (!selectedOrganizationId) return []; + if (!selectedUnitId) return []; return positionTypes.filter( (type: PositionTypeDto) => type.id !== positionTypeId && - (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + (type.isSystem || type.unitId === selectedUnitId), ); - }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + }, [positionTypes, selectedUnitId, positionTypeId]); // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. The copy source is cleared - // too — it is scoped to the old organization. + // different org can't be submitted by mistake. useEffect(() => { if (mode === "edit") return; form.setValue("unitId", ""); - setCopyFromPositionId(""); }, [selectedOrganizationId, mode, form]); + // The copy source is scoped to the selected unit — clear it whenever the + // unit changes (including as a side effect of the org reset above) so a + // stale selection from a different unit can't be submitted. + useEffect(() => { + setCopyFromPositionId(""); + }, [selectedUnitId]); + useEffect(() => { if (mode !== "edit" || !initialValues || !positionTypeId) return; if (hasLoadedEditData.current) return; @@ -335,11 +332,13 @@ export const CreatePositionForm = ({ const copyFromPlaceholder = !selectedOrganizationId ? t("contentManagement.selectOrganizationToCopy") - : isCopying || isLoadingPositionTypes || isLoadingUnits - ? t("common.loading") - : isErrorPositionTypes - ? t("contentManagement.failedToLoadPositionTypes") - : t("contentManagement.selectPositionToCopy"); + : !selectedUnitId + ? t("contentManagement.selectUnitToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); return (
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({ onValueChange={handleCopyFrom} disabled={ !selectedOrganizationId || + !selectedUnitId || isLoadingPositionTypes || isLoadingUnits || isCopying diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index 76d3182ad..af29cdc5e 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -19,6 +19,7 @@ import { AppLayout } from "./Applayout"; import ActivityLogPage from "@/pages/ActivityLogPage"; import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage"; import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage"; +import OrgAdminPermissionsPage from "@/super-admin/components/org-admins/OrgAdminPermissionsPage"; import UserProfileEditPage from "@/pages/UserProfileEditPage"; import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage"; import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage"; @@ -197,6 +198,10 @@ export function UserManagementRoutes(): ReactElement { path="user-management/organization_admins" element={} /> + } + /> }